feat(web): add WeCom QR binding flow to channel settings (#1994)

- add backend WeCom QR flow endpoints and in-memory flow state management
- add frontend WeCom binding UI with QR polling and channel enable toggle
- update channel config behavior and i18n strings for WeCom and WeChat
- apply minor formatting cleanup in model-related components
This commit is contained in:
wenjie
2026-03-25 16:15:04 +08:00
committed by GitHub
parent 6bd8fec87a
commit eb307e942b
11 changed files with 945 additions and 16 deletions
+6
View File
@@ -19,6 +19,8 @@ type Handler struct {
oauthState map[string]string
weixinMu sync.Mutex
weixinFlows map[string]*weixinFlow
wecomMu sync.Mutex
wecomFlows map[string]*wecomFlow
}
// NewHandler creates an instance of the API handler.
@@ -29,6 +31,7 @@ func NewHandler(configPath string) *Handler {
oauthFlows: make(map[string]*oauthFlow),
oauthState: make(map[string]string),
weixinFlows: make(map[string]*weixinFlow),
wecomFlows: make(map[string]*wecomFlow),
}
}
@@ -75,6 +78,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// WeChat QR login flow
h.registerWeixinRoutes(mux)
// WeCom QR login flow
h.registerWecomRoutes(mux)
}
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
+424
View File
@@ -0,0 +1,424 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"runtime"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
wecomFlowTTL = 5 * time.Minute
wecomFlowGCAge = 30 * time.Minute
wecomQRSourceID = "picoclaw"
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
wecomQRHTTPTimeout = 15 * time.Second
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
wecomPollStartTimeout = 15 * time.Second
wecomPollStatusTimeout = 10 * time.Second
)
const (
wecomStatusWait = "wait"
wecomStatusScanned = "scaned"
wecomStatusConfirmed = "confirmed"
wecomStatusExpired = "expired"
wecomStatusError = "error"
)
type wecomFlow struct {
ID string
SCode string
QRDataURI string
BotID string
Status string
Error string
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
type wecomFlowResponse struct {
FlowID string `json:"flow_id"`
Status string `json:"status"`
QRDataURI string `json:"qr_data_uri,omitempty"`
BotID string `json:"bot_id,omitempty"`
Error string `json:"error,omitempty"`
}
type wecomQRGenerateResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
SCode string `json:"scode"`
AuthURL string `json:"auth_url"`
} `json:"data"`
}
type wecomQRQueryResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
Status string `json:"status"`
BotInfo struct {
BotID string `json:"botid"`
Secret string `json:"secret"`
} `json:"bot_info"`
} `json:"data"`
}
// registerWecomRoutes binds WeCom QR login endpoints to the ServeMux.
func (h *Handler) registerWecomRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/wecom/flows", h.handleStartWecomFlow)
mux.HandleFunc("GET /api/wecom/flows/{id}", h.handlePollWecomFlow)
}
// handleStartWecomFlow starts a new WeCom QR login flow.
//
// POST /api/wecom/flows
func (h *Handler) handleStartWecomFlow(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), wecomPollStartTimeout)
defer cancel()
session, err := fetchWecomQRCode(ctx)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError)
return
}
dataURI, err := generateQRDataURI(session.Data.AuthURL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError)
return
}
now := time.Now()
flow := &wecomFlow{
ID: newWecomFlowID(),
SCode: session.Data.SCode,
QRDataURI: dataURI,
Status: wecomStatusWait,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(wecomFlowTTL),
}
h.storeWecomFlow(flow)
logger.InfoCF("wecom", "QR flow started", map[string]any{"flow_id": flow.ID})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
QRDataURI: flow.QRDataURI,
})
}
// handlePollWecomFlow polls the WeCom API for QR code status and updates the flow.
//
// GET /api/wecom/flows/{id}
func (h *Handler) handlePollWecomFlow(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.getWecomFlow(flowID)
if !ok {
http.Error(w, "flow not found", http.StatusNotFound)
return
}
if flow.Status == wecomStatusConfirmed ||
flow.Status == wecomStatusExpired ||
flow.Status == wecomStatusError {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
BotID: flow.BotID,
Error: flow.Error,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), wecomPollStatusTimeout)
defer cancel()
statusResp, err := queryWecomQRCodeStatus(ctx, flow.SCode)
if err != nil {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(wecomFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
QRDataURI: flow.QRDataURI,
})
return
}
switch strings.ToLower(statusResp.Data.Status) {
case wecomStatusWait:
// no-op
case wecomStatusScanned, "scanned":
h.updateWecomFlowStatus(flowID, wecomStatusScanned)
case "success":
if statusResp.Data.BotInfo.BotID == "" || statusResp.Data.BotInfo.Secret == "" {
h.setWecomFlowError(flowID, "login confirmed but missing bot credentials")
break
}
if saveErr := h.saveWecomBinding(
statusResp.Data.BotInfo.BotID,
statusResp.Data.BotInfo.Secret,
); saveErr != nil {
h.setWecomFlowError(flowID, fmt.Sprintf("failed to save credentials: %v", saveErr))
logger.ErrorCF("wecom", "failed to save credentials", map[string]any{"error": saveErr.Error()})
break
}
h.setWecomFlowConfirmed(flowID, statusResp.Data.BotInfo.BotID)
logger.InfoCF("wecom", "QR login confirmed, credentials saved", map[string]any{
"flow_id": flowID,
"bot_id": statusResp.Data.BotInfo.BotID,
})
case wecomStatusExpired:
h.updateWecomFlowStatus(flowID, wecomStatusExpired)
}
flow, _ = h.getWecomFlow(flowID)
w.Header().Set("Content-Type", "application/json")
resp := wecomFlowResponse{
FlowID: flow.ID,
Status: flow.Status,
BotID: flow.BotID,
Error: flow.Error,
}
if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned {
resp.QRDataURI = flow.QRDataURI
}
_ = json.NewEncoder(w).Encode(resp)
}
func (h *Handler) saveWecomBinding(botID, secret string) error {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.Channels.WeCom.Enabled = true
cfg.Channels.WeCom.BotID = botID
cfg.Channels.WeCom.SetSecret(secret)
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
return err
}
status := h.gatewayStatusData()
gatewayStatus, _ := status["gateway_status"].(string)
if gatewayStatus != "running" {
return nil
}
if _, err := h.RestartGateway(); err != nil {
logger.ErrorCF("wecom", "failed to restart gateway after saving binding", map[string]any{
"error": err.Error(),
})
}
return nil
}
func fetchWecomQRCode(ctx context.Context) (wecomQRGenerateResponse, error) {
targetURL, err := buildWecomQRGenerateURL(wecomQRGenerateEndpoint, wecomQRSourceID, wecomPlatformCode())
if err != nil {
return wecomQRGenerateResponse{}, err
}
var resp wecomQRGenerateResponse
if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil {
return wecomQRGenerateResponse{}, err
}
if resp.ErrCode != 0 {
return wecomQRGenerateResponse{}, fmt.Errorf(
"errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
return wecomQRGenerateResponse{}, fmt.Errorf("response missing scode or auth_url")
}
return resp, nil
}
func queryWecomQRCodeStatus(ctx context.Context, scode string) (wecomQRQueryResponse, error) {
targetURL, err := buildWecomQRQueryURL(wecomQRQueryEndpoint, scode)
if err != nil {
return wecomQRQueryResponse{}, err
}
var resp wecomQRQueryResponse
if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil {
return wecomQRQueryResponse{}, err
}
if resp.ErrCode != 0 {
return wecomQRQueryResponse{}, fmt.Errorf(
"errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
return resp, nil
}
func buildWecomQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
}
query := u.Query()
query.Set("source", sourceID)
query.Set("sourceID", sourceID)
query.Set("plat", strconv.Itoa(platformCode))
u.RawQuery = query.Encode()
return u.String(), nil
}
func buildWecomQRQueryURL(baseURL, scode string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
}
query := u.Query()
query.Set("scode", scode)
u.RawQuery = query.Encode()
return u.String(), nil
}
func doWecomJSONGet(ctx context.Context, targetURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: wecomQRHTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if readErr != nil {
return fmt.Errorf("unexpected status %s", resp.Status)
}
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode JSON response: %w", err)
}
return nil
}
func wecomPlatformCode() int {
switch runtime.GOOS {
case "darwin":
return 1
case "windows":
return 2
case "linux":
return 3
default:
return 0
}
}
func newWecomFlowID() string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("wc_%d", time.Now().UnixNano())
}
return "wc_" + hex.EncodeToString(buf)
}
func (h *Handler) storeWecomFlow(flow *wecomFlow) {
h.wecomMu.Lock()
defer h.wecomMu.Unlock()
h.gcWecomFlowsLocked(time.Now())
h.wecomFlows[flow.ID] = flow
}
func (h *Handler) getWecomFlow(flowID string) (*wecomFlow, bool) {
h.wecomMu.Lock()
defer h.wecomMu.Unlock()
h.gcWecomFlowsLocked(time.Now())
flow, ok := h.wecomFlows[flowID]
if !ok {
return nil, false
}
cp := *flow
return &cp, true
}
func (h *Handler) updateWecomFlowStatus(flowID, status string) {
h.wecomMu.Lock()
defer h.wecomMu.Unlock()
if flow, ok := h.wecomFlows[flowID]; ok {
flow.Status = status
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) setWecomFlowConfirmed(flowID, botID string) {
h.wecomMu.Lock()
defer h.wecomMu.Unlock()
if flow, ok := h.wecomFlows[flowID]; ok {
flow.Status = wecomStatusConfirmed
flow.BotID = botID
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) setWecomFlowError(flowID, errMsg string) {
h.wecomMu.Lock()
defer h.wecomMu.Unlock()
if flow, ok := h.wecomFlows[flowID]; ok {
flow.Status = wecomStatusError
flow.Error = errMsg
flow.UpdatedAt = time.Now()
}
}
func (h *Handler) gcWecomFlowsLocked(now time.Time) {
for id, flow := range h.wecomFlows {
if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned {
if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
flow.Status = wecomStatusExpired
flow.UpdatedAt = now
}
}
if flow.Status != wecomStatusWait &&
flow.Status != wecomStatusScanned &&
now.Sub(flow.UpdatedAt) > wecomFlowGCAge {
delete(h.wecomFlows, id)
}
}
}
+20
View File
@@ -72,6 +72,14 @@ export interface WeixinFlowResponse {
error?: string
}
export interface WecomFlowResponse {
flow_id: string
status: "wait" | "scaned" | "confirmed" | "expired" | "error"
qr_data_uri?: string
bot_id?: string
error?: string
}
export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
}
@@ -84,4 +92,16 @@ export async function pollWeixinFlow(
)
}
export async function startWecomFlow(): Promise<WecomFlowResponse> {
return request<WecomFlowResponse>("/api/wecom/flows", { method: "POST" })
}
export async function pollWecomFlow(
flowID: string,
): Promise<WecomFlowResponse> {
return request<WecomFlowResponse>(
`/api/wecom/flows/${encodeURIComponent(flowID)}`,
)
}
export type { ChannelsCatalogResponse, ConfigActionResponse }
@@ -1,4 +1,4 @@
import { IconLoader2 } from "@tabler/icons-react"
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
@@ -15,6 +15,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 { WecomForm } from "@/components/channels/channel-forms/wecom-form"
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
@@ -186,7 +187,7 @@ function getRequiredFieldKeys(channelName: string): string[] {
case "onebot":
return ["ws_url"]
case "wecom":
return ["bot_id", "secret"]
return []
case "whatsapp":
return ["bridge_url"]
case "pico":
@@ -326,6 +327,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
return getChannelDisplayName(channel, t)
}, [channel, channelName, t])
const hidesPageLevelEnableToggle = channel?.name === "wecom"
const hiddenKeys = useMemo(() => {
if (!channel) return []
if (channel.name === "whatsapp") {
@@ -410,6 +413,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
}
}, [loadData, t])
const handleWecomBindSuccess = useCallback(async () => {
try {
setEnabled(true)
await Promise.all([loadData(true), refreshGatewayState({ force: true })])
} catch (e) {
const message =
e instanceof Error ? e.message : t("channels.page.saveError")
setServerError(message)
await loadData(true)
}
}, [loadData, t])
const handleWecomEnabledChange = useCallback(
async (nextEnabled: boolean) => {
try {
setEnabled(nextEnabled)
await Promise.all([
loadData(true),
refreshGatewayState({ force: true }),
])
} catch (e) {
const message =
e instanceof Error ? e.message : t("channels.page.saveError")
setServerError(message)
await loadData(true)
}
},
[loadData, t],
)
const renderForm = () => {
if (!channel) return null
const isEdit = configured
@@ -460,6 +493,27 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onBindSuccess={() => void handleWeixinBindSuccess()}
/>
)
case "wecom":
return (
<>
<WecomForm
config={editConfig}
isEdit={isEdit}
onBindSuccess={() => void handleWecomBindSuccess()}
onEnabledChange={(nextEnabled) =>
void handleWecomEnabledChange(nextEnabled)
}
/>
<GenericForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
hiddenKeys={[...hiddenKeys, "bot_id"]}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
/>
</>
)
default:
return (
<GenericForm
@@ -524,12 +578,33 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
)}
</div>
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{channel?.name === "weixin" && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<div className="flex items-start gap-3">
<IconAlertTriangle
size={18}
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<div className="space-y-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{t("channels.weixin.warningTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
{t("channels.weixin.warningDesc")}
</p>
</div>
</div>
</div>
)}
{!hidesPageLevelEnableToggle && (
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
)}
{renderForm()}
@@ -123,7 +123,9 @@ export function GenericForm({
bot_id: t("channels.form.desc.appId"),
websocket_url: t("channels.form.desc.wsUrl"),
dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }),
group_policy: t("channels.form.desc.genericField", { field: "group policy" }),
group_policy: t("channels.form.desc.genericField", {
field: "group policy",
}),
group_allow_from: t("channels.form.desc.allowFrom"),
send_thinking_message: t("channels.form.desc.genericField", {
field: "thinking message behavior",
@@ -0,0 +1,367 @@
import {
IconCheck,
IconLoader2,
IconQrcode,
IconRefresh,
IconX,
} from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
type BindingState =
| "idle"
| "loading"
| "waiting"
| "scaned"
| "confirmed"
| "expired"
| "error"
interface WecomFormProps {
config: ChannelConfig
isEdit: boolean
onBindSuccess?: () => void
onEnabledChange?: (enabled: boolean) => void
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
export function WecomForm({
config,
isEdit,
onBindSuccess,
onEnabledChange,
}: WecomFormProps) {
const { t } = useTranslation()
const [bindState, setBindState] = useState<BindingState>("idle")
const [qrDataURI, setQrDataURI] = useState<string | null>(null)
const [botID, setBotID] = useState<string | null>(null)
const [errorMsg, setErrorMsg] = useState("")
const [enabled, setEnabled] = useState(config.enabled === true)
const [toggleSaving, setToggleSaving] = useState(false)
const [toggleError, setToggleError] = useState("")
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollGenerationRef = useRef(0)
const existingBotID = asString(config.bot_id)
const isBound = isEdit && existingBotID !== ""
const stopPolling = useCallback(() => {
pollGenerationRef.current += 1
if (pollTimerRef.current !== null) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}, [])
useEffect(() => () => stopPolling(), [stopPolling])
useEffect(() => {
setEnabled(config.enabled === true)
}, [config.enabled])
useEffect(() => {
if (!existingBotID) return
stopPolling()
setBotID(existingBotID)
setBindState("confirmed")
setErrorMsg("")
}, [existingBotID, stopPolling])
const startPolling = useCallback(
(id: string) => {
stopPolling()
const generation = pollGenerationRef.current
let inFlight = false
pollTimerRef.current = setInterval(async () => {
if (inFlight) return
inFlight = true
try {
const resp = await pollWecomFlow(id)
if (generation !== pollGenerationRef.current) {
return
}
if (resp.status === "scaned") {
setBindState("scaned")
} else if (resp.status === "confirmed") {
stopPolling()
setBotID(resp.bot_id ?? existingBotID ?? 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.wecom.errorGeneric"))
}
} catch {
// transient network error — keep polling
} finally {
inFlight = false
}
}, 2000)
},
[existingBotID, onBindSuccess, stopPolling, t],
)
const handleEnabledChange = useCallback(
async (checked: boolean) => {
if (!existingBotID || toggleSaving) {
return
}
setToggleSaving(true)
setToggleError("")
try {
await patchAppConfig({
channels: {
wecom: {
enabled: checked,
},
},
})
setEnabled(checked)
onEnabledChange?.(checked)
} catch (e) {
setToggleError(
e instanceof Error ? e.message : t("channels.wecom.errorGeneric"),
)
} finally {
setToggleSaving(false)
}
},
[existingBotID, onEnabledChange, t, toggleSaving],
)
const handleBind = async () => {
setBindState("loading")
setErrorMsg("")
setToggleError("")
setQrDataURI(null)
stopPolling()
try {
const resp = await startWecomFlow()
setQrDataURI(resp.qr_data_uri ?? null)
setBindState("waiting")
startPolling(resp.flow_id)
} catch (e) {
setBindState("error")
setErrorMsg(
e instanceof Error ? e.message : t("channels.wecom.errorGeneric"),
)
}
}
const handleRebind = () => {
stopPolling()
setBindState("idle")
setQrDataURI(null)
setBotID(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.wecom.bound")}
</div>
{existingBotID && (
<p className="text-muted-foreground font-mono text-xs">
{existingBotID}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={handleRebind}
className="mt-1 gap-2"
>
<IconRefresh size={14} />
{t("channels.wecom.rebind")}
</Button>
</div>
)
}
return (
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-muted-foreground text-sm">
{t("channels.wecom.notBound")}
</p>
<Button onClick={handleBind} className="gap-2">
<IconQrcode size={16} />
{t("channels.wecom.bind")}
</Button>
</div>
)
}
if (bindState === "loading") {
return (
<div className="flex flex-col items-center gap-3 py-8">
<IconLoader2
className="text-muted-foreground animate-spin"
size={32}
/>
<p className="text-muted-foreground text-sm">
{t("channels.wecom.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="WeCom QR Code"
className="border-border/60 h-48 w-48 rounded-xl border bg-white p-2 shadow-sm"
/>
) : (
<div className="border-border/60 bg-muted flex h-48 w-48 items-center justify-center rounded-xl border">
<IconLoader2
className="text-muted-foreground animate-spin"
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.wecom.scanned")}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("channels.wecom.scanHint")}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={handleRebind}
className="text-muted-foreground"
>
<IconRefresh size={14} className="mr-1" />
{t("channels.wecom.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.wecom.bound")}
</p>
{botID && (
<p className="text-muted-foreground font-mono text-xs">{botID}</p>
)}
<Button
variant="outline"
size="sm"
onClick={handleRebind}
className="mt-1 gap-2"
>
<IconRefresh size={14} />
{t("channels.wecom.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.wecom.expired")}
</p>
<Button onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.wecom.retry")}
</Button>
</div>
)
}
if (bindState === "error") {
return (
<div className="flex flex-col items-center gap-4 py-6">
<div className="bg-destructive/10 flex h-14 w-14 items-center justify-center rounded-full">
<IconX size={28} className="text-destructive" />
</div>
<p className="text-destructive text-sm">
{errorMsg || t("channels.wecom.errorGeneric")}
</p>
<Button variant="outline" onClick={handleRebind} className="gap-2">
<IconRefresh size={14} />
{t("channels.wecom.retry")}
</Button>
</div>
)
}
return null
}
return (
<div className="space-y-5">
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">
{t("channels.page.enableLabel")}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{isBound
? t("channels.wecom.enableDesc")
: t("channels.wecom.enableBindFirst")}
</p>
</div>
<Switch
checked={enabled}
disabled={!isBound || toggleSaving}
onCheckedChange={(checked) => void handleEnabledChange(checked)}
/>
</div>
{toggleError && (
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
)}
</div>
<div className="border-border/60 bg-muted/30 rounded-xl border">
<div className="border-border/60 border-b px-4 py-3">
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("channels.wecom.bindDesc")}
</p>
</div>
{renderBindSection()}
</div>
</div>
)
}
@@ -12,7 +12,6 @@ import {
} from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Sheet,
SheetContent,
@@ -21,6 +20,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
interface AddForm {
modelName: string
@@ -103,7 +103,8 @@ export function AddModelSheet({
}
const setField =
(key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
(key: keyof AddForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((f) => ({ ...f, [key]: e.target.value }))
if (fieldErrors[key]) {
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
@@ -12,7 +12,6 @@ import {
} from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Sheet,
SheetContent,
@@ -21,6 +20,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
interface EditForm {
apiKey: string
@@ -92,7 +92,8 @@ export function EditModelSheet({
}, [model])
const setField =
(key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(key: keyof EditForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
const handleSave = async () => {
@@ -28,7 +28,8 @@ export function ModelCard({
}: ModelCardProps) {
const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth"
const canSetDefault = model.configured && !model.is_default && !model.is_virtual
const canSetDefault =
model.configured && !model.is_default && !model.is_virtual
return (
<div
+17 -1
View File
@@ -247,7 +247,6 @@
"weixin": {
"warningTitle": "Testing phase, use with caution",
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
"bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.",
"bindTitle": "WeChat Account Binding",
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
"bind": "Bind WeChat",
@@ -262,6 +261,23 @@
"refresh": "Refresh QR",
"errorGeneric": "An error occurred. Please try again."
},
"wecom": {
"bindTitle": "WeCom Binding",
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
"enableDesc": "Once bound, you can enable or disable the channel here.",
"enableBindFirst": "Bind the bot first, then enable the channel.",
"bind": "Bind WeCom",
"rebind": "Re-bind",
"bound": "WeCom Bound",
"notBound": "WeCom AI Bot not bound yet.",
"generating": "Generating QR code...",
"scanHint": "Open WeCom and scan the QR code",
"scanned": "Scanned, please confirm in WeCom",
"expired": "QR code expired",
"retry": "Try Again",
"refresh": "Refresh QR",
"errorGeneric": "An error occurred. Please try again."
},
"field": {
"token": "Bot Token",
"tokenPlaceholder": "Enter bot token",
+17 -1
View File
@@ -247,7 +247,6 @@
"weixin": {
"warningTitle": "测试阶段,请谨慎使用",
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
"bindEnableSuccess": "微信已连接,频道已自动启用。",
"bindTitle": "微信账号绑定",
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
"bind": "绑定微信",
@@ -262,6 +261,23 @@
"refresh": "刷新二维码",
"errorGeneric": "发生错误,请重试。"
},
"wecom": {
"bindTitle": "企业微信绑定",
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
"enableDesc": "绑定后可在这里直接启用或停用频道。",
"enableBindFirst": "请先完成绑定,然后再启用频道。",
"bind": "绑定企业微信",
"rebind": "重新绑定",
"bound": "企业微信已绑定",
"notBound": "尚未绑定企业微信 AI Bot。",
"generating": "正在生成二维码...",
"scanHint": "打开企业微信,扫描二维码",
"scanned": "已扫码,请在企业微信中确认",
"expired": "二维码已过期",
"retry": "重试",
"refresh": "刷新二维码",
"errorGeneric": "发生错误,请重试。"
},
"field": {
"token": "Bot Token",
"tokenPlaceholder": "输入 Bot Token",