You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

914 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"html/template"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
qrcode "github.com/skip2/go-qrcode"
)
const (
baseURL = "https://www.watvt.com"
listenAddr = "127.0.0.1:8787"
localSessionName = "jintai_local_sid"
// 改成你自己的身份证号,也可以用环境变量 JINTAI_ID_CARD 覆盖。
hardcodedIDCard = "请改成你的身份证号"
)
var idCardRE = regexp.MustCompile(`^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9]|X)$`)
type server struct {
mu sync.Mutex
sessions map[string]*jintaiClient
}
type jintaiClient struct {
mu sync.Mutex
client *http.Client
}
type apiResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
type pageData struct {
IDMasked string
VisitDate string
Captcha string
CaptchaImage template.URL
StatusClass string
StatusMessage string
ResultHTML template.HTML
RawJSON string
}
func main() {
srv := &server{sessions: make(map[string]*jintaiClient)}
mux := http.NewServeMux()
mux.HandleFunc("/", srv.handleHome)
log.Printf("金泰访客查询本地服务已启动: http://%s", listenAddr)
log.Fatal(http.ListenAndServe(listenAddr, mux))
}
func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
idCard := configuredIDCard()
visitDate := strings.TrimSpace(r.URL.Query().Get("date"))
if visitDate == "" {
visitDate = todayText()
}
data := pageData{
IDMasked: maskIDCard(idCard),
VisitDate: visitDate,
Captcha: strings.TrimSpace(r.URL.Query().Get("captcha")),
StatusClass: "muted",
StatusMessage: "等待验证码。",
}
if !idCardRE.MatchString(idCard) {
data.StatusClass = "err"
data.StatusMessage = "请先在 Go 文件里修改 hardcodedIDCard或设置环境变量 JINTAI_ID_CARD。"
renderPage(w, data)
return
}
client := s.clientFor(w, r)
if data.Captcha != "" {
raw, err := client.queryVisitor(r.Context(), idCard, visitDate, data.Captcha)
data.RawJSON = prettyJSON(raw)
if err != nil {
data.StatusClass = "err"
data.StatusMessage = "查询失败:" + err.Error()
} else {
var api apiResponse
if json.Unmarshal(raw, &api) == nil {
if api.Success {
data.StatusClass = "ok"
data.StatusMessage = "查询完成。"
} else {
data.StatusClass = "err"
data.StatusMessage = firstNonEmpty(api.Message, "查询接口返回失败。")
}
} else {
data.StatusClass = "ok"
data.StatusMessage = "查询完成,返回内容不是标准 JSON。"
}
data.ResultHTML = renderResultHTML(raw)
}
}
if data.Captcha == "" || data.StatusClass == "err" {
img, err := client.fetchCaptcha(r.Context(), idCard)
if err != nil {
data.StatusClass = "err"
data.StatusMessage = "验证码获取失败:" + err.Error()
} else {
data.Captcha = ""
data.CaptchaImage = template.URL("data:image/png;base64," + img)
if data.ResultHTML == "" {
data.StatusClass = "ok"
data.StatusMessage = "验证码已加载,输入后点查询。"
}
}
}
renderPage(w, data)
}
func configuredIDCard() string {
if v := strings.TrimSpace(os.Getenv("JINTAI_ID_CARD")); v != "" {
return v
}
return strings.TrimSpace(hardcodedIDCard)
}
func newJintaiClient() *jintaiClient {
jar, _ := cookiejar.New(nil)
return &jintaiClient{
client: &http.Client{
Jar: jar,
Timeout: 25 * time.Second,
},
}
}
func (s *server) clientFor(w http.ResponseWriter, r *http.Request) *jintaiClient {
s.mu.Lock()
defer s.mu.Unlock()
if c, err := r.Cookie(localSessionName); err == nil && c.Value != "" {
if client := s.sessions[c.Value]; client != nil {
return client
}
}
sid := randomID()
client := newJintaiClient()
s.sessions[sid] = client
http.SetCookie(w, &http.Cookie{
Name: localSessionName,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return client
}
func randomID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func (c *jintaiClient) fetchCaptcha(ctx context.Context, idCard string) (string, error) {
raw, err := c.postEncoded(ctx, "/boot/system/getCaptcha", map[string]string{
"username": idCard,
})
if err != nil {
return "", err
}
var api apiResponse
if err := json.Unmarshal(raw, &api); err != nil {
return "", fmt.Errorf("验证码响应不是 JSON: %s", string(raw))
}
if !api.Success {
return "", errors.New(firstNonEmpty(api.Message, string(raw)))
}
if api.Message == "" {
return "", errors.New("验证码图片为空")
}
return api.Message, nil
}
func (c *jintaiClient) queryVisitor(ctx context.Context, idCard, visitDate, captcha string) ([]byte, error) {
return c.postEncoded(ctx, "/boot/system/visitor/getVisitorHisByCard", map[string]string{
"身份证号": idCard,
"随行人员": visitDate,
"备注": captcha,
})
}
func (c *jintaiClient) postEncoded(ctx context.Context, path string, payload any) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
_ = c.warmup(ctx)
enc, err := encodeStr(payload)
if err != nil {
return nil, err
}
body, err := json.Marshal(map[string]string{"encodeStr": enc})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("X-Access-Type", "app")
req.Header.Set("Origin", baseURL)
req.Header.Set("Referer", baseURL+"/app/#/user/visitorSearch")
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
return normalizeResponse(data)
}
func (c *jintaiClient) warmup(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/app/", nil)
if err != nil {
return err
}
req.Header.Set("Referer", baseURL+"/app/#/user/visitorSearch")
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := c.client.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
}
func encodeStr(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
escaped := url.QueryEscape(string(b))
escaped = strings.ReplaceAll(escaped, "+", "%20")
return base64.StdEncoding.EncodeToString([]byte(escaped)), nil
}
func decodeStr(s string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return "", err
}
return url.QueryUnescape(string(raw))
}
func normalizeResponse(data []byte) ([]byte, error) {
var wrapper struct {
ResponseEncodeStr string `json:"responseEncodeStr"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.ResponseEncodeStr != "" {
decoded, err := decodeStr(wrapper.ResponseEncodeStr)
if err != nil {
return data, err
}
return []byte(decoded), nil
}
return data, nil
}
func renderPage(w http.ResponseWriter, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pageTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func renderResultHTML(raw []byte) template.HTML {
if len(bytes.TrimSpace(raw)) == 0 {
return ""
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return template.HTML(`<div class="empty">返回内容不是 JSON已显示在原始响应里。</div>`)
}
result := value
message := ""
if obj, ok := value.(map[string]any); ok {
if msg, _ := obj["message"].(string); msg != "" {
message = msg
}
if v, ok := obj["result"]; ok {
result = v
}
}
var b strings.Builder
b.WriteString(`<div class="result-block">`)
if message != "" {
b.WriteString(`<div class="api-message">` + html.EscapeString(message) + `</div>`)
}
records := collectRecords(result)
if len(records) > 0 {
for i, record := range records {
b.WriteString(renderVisitorCard(record, i+1, len(records)))
}
b.WriteString(`<details class="field-details"><summary>详细字段</summary>`)
b.WriteString(renderAny(result, "访客记录"))
b.WriteString(`</details>`)
} else {
b.WriteString(renderAny(result, "访客记录"))
}
b.WriteString(`</div>`)
return template.HTML(b.String())
}
func collectRecords(v any) []map[string]any {
switch x := v.(type) {
case []any:
records := make([]map[string]any, 0, len(x))
for _, item := range x {
if record, ok := item.(map[string]any); ok {
records = append(records, record)
}
}
return records
case map[string]any:
if len(x) == 0 {
return nil
}
return []map[string]any{x}
case string:
var nested any
if json.Unmarshal([]byte(x), &nested) == nil {
return collectRecords(nested)
}
}
return nil
}
func renderVisitorCard(obj map[string]any, index, total int) string {
name := firstField(obj, "姓名", "访客姓名")
if name == "" {
name = "访客"
}
gender := firstField(obj, "性别", "性别_dict", "性别_dictText")
title := "访客"
if gender == "男" {
title = "先生"
} else if gender == "女" {
title = "女士"
}
company := firstField(obj, "访客公司", "公司", "单位")
start := firstField(obj, "开始时间", "来访时间")
end := firstField(obj, "结束时间", "离开时间")
reason := firstField(obj, "来访事由_dictText", "来访事由_dict", "来访事由")
host := firstField(obj, "接待人名称", "接待人", "person")
qrText := firstField(obj, "备注", "二维码", "通行码", "访客码", "QRCode", "qrCode", "qr")
qrSrc, qrErr := qrImageDataURL(qrText)
var b strings.Builder
b.WriteString(`<article class="visitor-card">`)
b.WriteString(`<div class="card-head"><span>访客证</span>`)
if total > 1 {
b.WriteString(`<strong>` + html.EscapeString(fmt.Sprintf("%d / %d", index, total)) + `</strong>`)
}
b.WriteString(`</div>`)
b.WriteString(`<div class="welcome"><span>尊敬的</span><b>` + html.EscapeString(name) + `</b><span>` + html.EscapeString(title) + `</span></div>`)
if qrSrc != "" {
b.WriteString(`<div class="qr-wrap"><img class="qr-code" src="` + html.EscapeString(qrSrc) + `" alt="访客二维码"></div>`)
} else {
msg := "记录里没有可生成二维码的备注字段。"
if qrErr != nil {
msg = qrErr.Error()
}
b.WriteString(`<div class="qr-missing">` + html.EscapeString(msg) + `</div>`)
}
b.WriteString(`<div class="qr-tip">进园区时,请出示此二维码</div>`)
b.WriteString(`<div class="company">` + html.EscapeString(firstNonEmpty(company, "-")) + `</div>`)
b.WriteString(`<div class="card-meta">`)
b.WriteString(renderMetaRow("开始时间", start))
b.WriteString(renderMetaRow("结束时间", end))
b.WriteString(renderMetaRow("来访事由", reason))
b.WriteString(renderMetaRow("接待人", host))
b.WriteString(`</div></article>`)
return b.String()
}
func renderMetaRow(label, value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return `<div class="meta-row"><span>` + html.EscapeString(label) + `</span><strong>` + html.EscapeString(value) + `</strong></div>`
}
func renderAny(v any, title string) string {
switch x := v.(type) {
case map[string]any:
if result, ok := x["result"]; ok {
var b strings.Builder
if msg, _ := x["message"].(string); msg != "" {
b.WriteString(`<div class="api-message">` + html.EscapeString(msg) + `</div>`)
}
b.WriteString(renderAny(result, "访客记录"))
return b.String()
}
return renderObject(x, title)
case []any:
if len(x) == 0 {
return `<div class="empty">没有查到访客记录。</div>`
}
var b strings.Builder
for i, item := range x {
b.WriteString(renderAny(item, fmt.Sprintf("记录 %d", i+1)))
}
return b.String()
case string:
return `<div class="single-value">` + html.EscapeString(x) + `</div>`
default:
return `<div class="single-value">` + html.EscapeString(fmt.Sprint(x)) + `</div>`
}
}
func renderObject(obj map[string]any, title string) string {
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(`<article class="record">`)
b.WriteString(`<h2>` + html.EscapeString(title) + `</h2>`)
b.WriteString(`<div class="fields">`)
for _, k := range keys {
value := stringify(obj[k])
b.WriteString(`<div class="field">`)
b.WriteString(`<div class="field-key">` + html.EscapeString(k) + `</div>`)
b.WriteString(`<div class="field-value">`)
if src, ok := imageSrc(k, value); ok {
b.WriteString(`<img class="result-image" src="` + html.EscapeString(src) + `" alt="` + html.EscapeString(k) + `">`)
b.WriteString(`<div class="image-text">` + html.EscapeString(value) + `</div>`)
} else {
b.WriteString(html.EscapeString(value))
}
b.WriteString(`</div></div>`)
}
b.WriteString(`</div></article>`)
return b.String()
}
func firstField(obj map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := obj[key]; ok {
if s := strings.TrimSpace(stringify(v)); s != "" {
return s
}
}
}
return ""
}
func qrImageDataURL(text string) (string, error) {
text = strings.TrimSpace(text)
if text == "" {
return "", errors.New("记录里没有可生成二维码的备注字段。")
}
png, err := qrcode.Encode(text, qrcode.Medium, 220)
if err != nil {
return "", fmt.Errorf("二维码生成失败:%w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
func stringify(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case float64, bool:
return fmt.Sprint(x)
default:
b, err := json.MarshalIndent(x, "", " ")
if err != nil {
return fmt.Sprint(x)
}
return string(b)
}
}
func imageSrc(key, value string) (string, bool) {
v := strings.TrimSpace(value)
if v == "" {
return "", false
}
lowerKey := strings.ToLower(key)
looksLikeImageField := strings.Contains(key, "二维码") ||
strings.Contains(key, "图片") ||
strings.Contains(lowerKey, "qr") ||
strings.Contains(lowerKey, "image")
if strings.HasPrefix(v, "data:image/") {
return v, true
}
if strings.HasPrefix(v, "/boot/") || strings.HasPrefix(v, "/app/") {
return baseURL + v, true
}
if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") {
u, err := url.Parse(v)
if err == nil {
p := strings.ToLower(u.Path)
if strings.HasSuffix(p, ".png") || strings.HasSuffix(p, ".jpg") ||
strings.HasSuffix(p, ".jpeg") || strings.HasSuffix(p, ".gif") ||
strings.HasSuffix(p, ".webp") || strings.HasSuffix(p, ".svg") ||
looksLikeImageField {
return v, true
}
}
}
if looksLikeImageField && len(v) > 80 && regexp.MustCompile(`^[A-Za-z0-9+/=]+$`).MatchString(v) {
return "data:image/png;base64," + v, true
}
return "", false
}
func prettyJSON(raw []byte) string {
if len(bytes.TrimSpace(raw)) == 0 {
return ""
}
var out bytes.Buffer
if json.Indent(&out, raw, "", " ") == nil {
return out.String()
}
return string(raw)
}
func todayText() string {
return time.Now().Format("2006-01-02")
}
func maskIDCard(id string) string {
if !idCardRE.MatchString(id) || len(id) < 14 {
return "未配置"
}
return id[:6] + "********" + id[len(id)-4:]
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var pageTemplate = template.Must(template.New("page").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>金泰访客查询</title>
<style>
:root {
--bg: #f4f7fb;
--panel: #fff;
--text: #1f2937;
--muted: #64748b;
--line: #dbe4ef;
--primary: #0877d8;
--primary-dark: #075da9;
--ok: #047857;
--err: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, rgba(8, 119, 216, .12), rgba(8, 119, 216, 0) 280px), var(--bg);
}
main { width: min(760px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 44px; }
h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.25; letter-spacing: 0; }
.sub { margin: 0 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
.panel, .record {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 16px 45px rgba(31, 41, 55, .08);
overflow: hidden;
}
.row { padding: 16px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 14px; }
input {
width: 100%;
height: 44px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 12px;
font-size: 16px;
outline: none;
}
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(8,119,216,.14); }
.id-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 14px; }
.id-line strong { color: var(--text); font-size: 16px; }
.captcha-grid { display: grid; grid-template-columns: 1fr 152px; gap: 10px; align-items: center; }
.captcha-img {
height: 44px;
border: 1px solid var(--line);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #fbfdff;
overflow: hidden;
}
.captcha-img img { max-width: 100%; max-height: 42px; display: block; }
button {
width: 100%;
height: 44px;
border: 0;
border-radius: 6px;
color: #fff;
background: var(--primary);
font-size: 15px;
cursor: pointer;
}
button:hover { background: var(--primary-dark); }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.secondary {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border-radius: 6px;
color: var(--primary);
background: #eaf4ff;
text-decoration: none;
font-size: 15px;
}
.secondary:hover { background: #d8ebff; }
.status { font-size: 14px; line-height: 1.7; }
.status.ok { color: var(--ok); }
.status.err { color: var(--err); }
.status.muted { color: var(--muted); }
.result-block { margin-top: 18px; }
.visitor-card {
max-width: 390px;
margin: 0 auto 14px;
background: #fff;
border: 1px solid #dcc698;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(85, 61, 23, .14);
overflow: hidden;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 18px;
color: #fff;
background: #8f6f34;
font-size: 18px;
font-weight: 700;
}
.card-head strong { font-size: 13px; font-weight: 600; }
.welcome {
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
padding: 20px 18px 12px;
font-size: 16px;
}
.welcome b { font-size: 22px; }
.qr-wrap {
display: flex;
justify-content: center;
padding: 8px 0 10px;
background: #fff;
}
.qr-code {
width: 220px;
height: 220px;
display: block;
image-rendering: pixelated;
}
.qr-missing {
margin: 12px 18px;
padding: 18px;
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--err);
text-align: center;
line-height: 1.6;
background: #fff7f7;
}
.qr-tip {
padding: 0 18px 14px;
color: #8f6f34;
text-align: center;
font-size: 14px;
}
.company {
padding: 14px 18px;
border-top: 1px solid #efe2c8;
border-bottom: 1px solid #efe2c8;
text-align: center;
font-size: 15px;
line-height: 1.6;
word-break: break-word;
}
.card-meta { padding: 12px 18px 16px; }
.meta-row {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 6px 0;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.meta-row strong {
color: var(--text);
font-weight: 500;
text-align: right;
word-break: break-word;
}
.field-details, .raw-json {
margin-top: 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.field-details summary, .raw-json summary {
cursor: pointer;
padding: 12px 14px;
color: var(--primary);
font-size: 14px;
user-select: none;
}
.api-message, .empty, .single-value {
padding: 14px 16px;
color: var(--muted);
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 12px;
}
.record { margin-bottom: 12px; box-shadow: none; }
.record h2 {
margin: 0;
padding: 14px 16px;
font-size: 17px;
border-bottom: 1px solid var(--line);
}
.fields { display: grid; grid-template-columns: 160px 1fr; }
.field { display: contents; }
.field-key, .field-value {
padding: 11px 14px;
border-bottom: 1px solid var(--line);
line-height: 1.55;
word-break: break-word;
white-space: pre-wrap;
font-size: 14px;
}
.field-key { color: var(--muted); background: #f8fafc; }
.field-value { color: var(--text); }
.result-image {
display: block;
max-width: min(240px, 100%);
max-height: 240px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
margin-bottom: 8px;
}
.image-text { color: var(--muted); font-size: 12px; }
pre {
margin: 0;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.55;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
padding: 14px;
}
@media (max-width: 560px) {
.captcha-grid, .actions, .fields { grid-template-columns: 1fr; }
.field-key { border-bottom: 0; padding-bottom: 4px; }
.field-value { padding-top: 4px; }
.visitor-card { max-width: 100%; }
.meta-row {
display: block;
}
.meta-row strong {
display: block;
margin-top: 2px;
text-align: left;
}
}
</style>
</head>
<body>
<main>
<h1>金泰访客查询</h1>
<p class="sub">本页由本地 Go 服务生成,只监听 127.0.0.1。服务端代发金泰请求,浏览器不再直接跨域访问。</p>
<section class="panel">
<div class="row">
<div class="id-line">
<span>身份证号</span>
<strong>{{.IDMasked}}</strong>
</div>
</div>
<form method="get" action="/">
<div class="row">
<label for="date">来访日期</label>
<input id="date" name="date" value="{{.VisitDate}}" placeholder="YYYY-MM-DD" autocomplete="off">
</div>
<div class="row">
<label for="captcha">验证码</label>
<div class="captcha-grid">
<input id="captcha" name="captcha" value="{{.Captcha}}" placeholder="请输入右侧验证码" autocomplete="off">
<div class="captcha-img">
{{if .CaptchaImage}}<img src="{{.CaptchaImage}}" alt="验证码">{{else}}验证码未加载{{end}}
</div>
</div>
</div>
<div class="row">
<div class="actions">
<a class="secondary" href="/">刷新验证码</a>
<button type="submit">查询</button>
</div>
</div>
</form>
<div class="row">
<div class="status {{.StatusClass}}">{{.StatusMessage}}</div>
</div>
</section>
{{.ResultHTML}}
{{if .RawJSON}}<details class="raw-json"><summary>原始响应</summary><pre>{{.RawJSON}}</pre></details>{{end}}
</main>
</body>
</html>`))