|
|
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"
|
|
|
)
|
|
|
|
|
|
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>`)
|
|
|
}
|
|
|
|
|
|
var b strings.Builder
|
|
|
b.WriteString(`<div class="result-block">`)
|
|
|
b.WriteString(renderAny(value, "查询结果"))
|
|
|
b.WriteString(`</div>`)
|
|
|
return template.HTML(b.String())
|
|
|
}
|
|
|
|
|
|
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 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; }
|
|
|
.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: 18px 0 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; }
|
|
|
}
|
|
|
</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}}<pre>{{.RawJSON}}</pre>{{end}}
|
|
|
</main>
|
|
|
</body>
|
|
|
</html>`))
|