apiserver.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. // ============================================================================
  2. // apiserver.go — 内置 API 服务
  3. //
  4. // 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、
  5. // 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。
  6. // ============================================================================
  7. package main
  8. import (
  9. "archive/zip"
  10. "context"
  11. "crypto/rand"
  12. "crypto/subtle"
  13. "encoding/base64"
  14. "encoding/binary"
  15. "fmt"
  16. "io"
  17. "log"
  18. "net/http"
  19. "net/url"
  20. "os"
  21. "path/filepath"
  22. "strconv"
  23. "strings"
  24. "sync"
  25. "time"
  26. "github.com/gen2brain/beeep" // 跨平台系统通知,Windows 下为托盘气泡/Toast
  27. "github.com/gin-gonic/gin"
  28. )
  29. // notifyRequest 通知接口的请求体
  30. type notifyRequest struct {
  31. Title string `json:"title"` // 通知标题,为空时使用应用名
  32. Message string `json:"message"` // 通知正文
  33. }
  34. // mouseAlertRequest 鼠标提醒接口的请求体
  35. type mouseAlertRequest struct {
  36. Text string `json:"text"` // 提示文字,为空时使用默认文案
  37. }
  38. // ------------------------------------------------------------------------
  39. // Gin 日志环形缓冲:日志同时写控制台与内存缓冲,供「主机」页实时查看
  40. // ------------------------------------------------------------------------
  41. const apiLogCapacity = 300 // 环形缓冲最多保留的日志行数
  42. // logRing 固定容量的日志环形缓冲,实现 io.Writer
  43. type logRing struct {
  44. mu sync.Mutex
  45. lines []string
  46. }
  47. // Write 按行拆分写入缓冲,并向所有前端窗口广播 "apilog" 事件
  48. func (r *logRing) Write(p []byte) (int, error) {
  49. text := strings.TrimRight(string(p), "\r\n")
  50. r.mu.Lock()
  51. for _, line := range strings.Split(text, "\n") {
  52. if strings.TrimSpace(line) == "" {
  53. continue
  54. }
  55. r.lines = append(r.lines, line)
  56. if len(r.lines) > apiLogCapacity {
  57. r.lines = r.lines[len(r.lines)-apiLogCapacity:] // 丢弃最旧的行
  58. }
  59. if app != nil {
  60. app.Event.Emit("apilog", line) // 实时推送到「主机」页
  61. }
  62. }
  63. r.mu.Unlock()
  64. return len(p), nil
  65. }
  66. // Snapshot 返回当前缓冲的全部日志行
  67. func (r *logRing) Snapshot() []string {
  68. r.mu.Lock()
  69. defer r.mu.Unlock()
  70. out := make([]string, len(r.lines))
  71. copy(out, r.lines)
  72. return out
  73. }
  74. // Clear 清空日志缓冲
  75. func (r *logRing) Clear() {
  76. r.mu.Lock()
  77. r.lines = nil
  78. r.mu.Unlock()
  79. }
  80. var apiLogRing = &logRing{}
  81. // ------------------------------------------------------------------------
  82. // API 服务生命周期:地址变化时平滑重启(仅监听配置的地址)
  83. // ------------------------------------------------------------------------
  84. var (
  85. apiMu sync.Mutex
  86. apiServer *http.Server
  87. apiAddr string // 当前监听地址
  88. apiStartedAt time.Time // 本次启动时间
  89. apiLastErr string // 最近一次启动 / 运行错误
  90. )
  91. // StopAPIServer 停止内置 API 服务(从服务页手动关闭)。
  92. // 停止后 apiServer 置 nil、apiAddr 保留,便于再次启动或恢复。
  93. func StopAPIServer() {
  94. apiMu.Lock()
  95. old := apiServer
  96. apiServer = nil
  97. apiAddr = ""
  98. apiLastErr = ""
  99. apiMu.Unlock()
  100. if old != nil {
  101. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  102. _ = old.Shutdown(ctx) // 平滑关闭,中断不复用连接
  103. cancel()
  104. }
  105. log.Printf("API server stopped by user")
  106. fmt.Fprintf(apiLogRing, "[SYS] API 服务已停止\n")
  107. }
  108. // StartAPIServer 启动 / 重启内置 API 服务(从服务页手动启动或恢复)。
  109. // 使用配置中的地址(为空时用默认地址)。
  110. func StartAPIServer() {
  111. ensureAPIServer(loadSettings().APIAddr)
  112. }
  113. // APIStatus API 服务运行状态,供「主机」页展示
  114. type APIStatus struct {
  115. Addr string `json:"addr"` // 当前监听地址
  116. Running bool `json:"running"` // 是否正在监听
  117. StartedAt string `json:"startedAt"` // 启动时间
  118. Error string `json:"error"` // 最近错误信息
  119. }
  120. // ensureAPIServer 确保 API 服务监听在指定地址;地址变化时先关闭旧服务再启动
  121. func ensureAPIServer(addr string) {
  122. addr = strings.TrimSpace(addr)
  123. if addr == "" {
  124. addr = DefaultAPIAddr
  125. }
  126. apiMu.Lock()
  127. if apiServer != nil && apiAddr == addr && apiLastErr == "" {
  128. apiMu.Unlock()
  129. return // 地址未变且运行正常,无需重启
  130. }
  131. old := apiServer
  132. apiServer = nil
  133. apiAddr = addr
  134. apiStartedAt = time.Now()
  135. apiLastErr = ""
  136. apiMu.Unlock()
  137. if old != nil {
  138. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  139. _ = old.Shutdown(ctx) // 平滑关闭旧服务,中断不复用连接
  140. cancel()
  141. }
  142. go runAPIServer(addr)
  143. }
  144. // runAPIServer 构建路由并启动监听(出错只记录状态,不影响应用本身)
  145. func runAPIServer(addr string) {
  146. gin.SetMode(gin.ReleaseMode) // 生产模式,减少日志噪音
  147. router := gin.New()
  148. router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
  149. Output: io.MultiWriter(os.Stdout, apiLogRing), // 日志同时写控制台与环形缓冲
  150. }))
  151. router.Use(gin.Recovery()) // 捕获 panic,避免接口崩溃导致进程退出
  152. router.Use(corsMiddleware()) // 允许跨域,便于前端或其他本机工具调用
  153. router.GET("/health", func(c *gin.Context) {
  154. c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
  155. })
  156. router.GET("/upload", handleUploadPage) // 客户端上传页(手机等设备浏览器访问)
  157. router.GET("/api/qr", handleQRImage) // 生成二维码图片(供客户端上传页/桌面端展示)
  158. api := router.Group("/api")
  159. api.POST("/notify", handleNotify) // 弹出系统托盘通知
  160. api.POST("/window/show", handleShowWindow) // 显示主窗口
  161. api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字)
  162. api.GET("/settings", handleGetSettings) // 读取配置
  163. api.POST("/settings", handleSaveSettings) // 保存配置
  164. api.GET("/stocks", handleGetStocks) // 获取股票行情
  165. api.GET("/screenshot", handleScreenshot) // 截取屏幕,返回 PNG 数据 URI
  166. api.POST("/upload", handleUpload) // 上传文件(需密码)
  167. api.GET("/download/:filename", handleDownload) // 下载已上传文件(需密码)
  168. pc := api.Group("/pc") // 主机控制接口(移植自主机服务)
  169. pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
  170. pc.GET("/restart", handleRestart) // 30 秒后重启
  171. pc.GET("/sleep", handleSleep) // 30 秒后休眠
  172. pc.GET("/lock", handleLock) // 立即锁屏
  173. pc.GET("/cancel", handleCancel) // 取消待执行命令
  174. pc.GET("/status", handlePCStatus) // 查询命令状态
  175. pc.GET("/timesync", handleTimeSync) // 同步系统时间
  176. server := &http.Server{Addr: addr, Handler: router}
  177. apiMu.Lock()
  178. apiServer = server
  179. apiMu.Unlock()
  180. log.Printf("API server listening on http://%s", addr)
  181. fmt.Fprintf(apiLogRing, "[SYS] API 服务已启动 http://%s\n", addr)
  182. if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  183. apiMu.Lock()
  184. apiLastErr = err.Error() // 端口被占用等错误只记录,不影响应用启动
  185. apiMu.Unlock()
  186. log.Printf("api server stopped: %v", err)
  187. fmt.Fprintf(apiLogRing, "[SYS] API 服务异常停止: %v\n", err)
  188. }
  189. }
  190. // getAPIStatus 返回当前 API 服务状态快照
  191. func getAPIStatus() APIStatus {
  192. apiMu.Lock()
  193. defer apiMu.Unlock()
  194. started := ""
  195. if !apiStartedAt.IsZero() {
  196. started = apiStartedAt.Format("01-02 15:04:05")
  197. }
  198. return APIStatus{
  199. Addr: apiAddr,
  200. Running: apiServer != nil,
  201. StartedAt: started,
  202. Error: apiLastErr,
  203. }
  204. }
  205. // corsMiddleware 简单放行跨域请求(服务默认仅监听 127.0.0.1,风险可控)
  206. func corsMiddleware() gin.HandlerFunc {
  207. return func(c *gin.Context) {
  208. c.Header("Access-Control-Allow-Origin", "*")
  209. c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  210. c.Header("Access-Control-Allow-Headers", "Content-Type")
  211. if c.Request.Method == http.MethodOptions {
  212. c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回
  213. return
  214. }
  215. c.Next()
  216. }
  217. }
  218. // handleNotify 通过系统通知弹出消息,例如:POST /api/notify {"message":"任务完成"}
  219. func handleNotify(c *gin.Context) {
  220. var req notifyRequest
  221. if err := c.ShouldBindJSON(&req); err != nil {
  222. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  223. return
  224. }
  225. if req.Title == "" {
  226. req.Title = "腾图工具箱" // 未指定标题时使用默认标题
  227. }
  228. if err := beeep.Notify(req.Title, req.Message, ""); err != nil {
  229. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  230. return
  231. }
  232. c.JSON(http.StatusOK, gin.H{"ok": true}) // 通知已发出
  233. }
  234. // handleMouseAlert 触发系统级鼠标提醒,例如:POST /api/mouse/alert {"text":"有新订单"}
  235. func handleMouseAlert(c *gin.Context) {
  236. var req mouseAlertRequest
  237. if err := c.ShouldBindJSON(&req); err != nil {
  238. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  239. return
  240. }
  241. triggerMouseAlert(req.Text) // 触发鼠标闪烁 + 光标下方透明文字
  242. c.JSON(http.StatusOK, gin.H{"ok": true})
  243. }
  244. // handleShowWindow 显示并聚焦主窗口(从托盘恢复)
  245. func handleShowWindow(c *gin.Context) {
  246. showMainWindow()
  247. c.JSON(http.StatusOK, gin.H{"ok": true})
  248. }
  249. // handleGetSettings 返回当前配置
  250. func handleGetSettings(c *gin.Context) {
  251. c.JSON(http.StatusOK, loadSettings())
  252. }
  253. // handleSaveSettings 保存配置并立即生效
  254. func handleSaveSettings(c *gin.Context) {
  255. var settings AppSettings
  256. if err := c.ShouldBindJSON(&settings); err != nil {
  257. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  258. return
  259. }
  260. settings = normalizeSettings(settings)
  261. settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
  262. if err := saveSettings(settings); err != nil {
  263. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  264. return
  265. }
  266. applySettings(settings)
  267. c.JSON(http.StatusOK, settings) // 返回校正后的配置
  268. }
  269. // handleGetStocks 获取股票行情;支持 ?codes=sh600519,sz000001 临时指定代码
  270. func handleGetStocks(c *gin.Context) {
  271. codes := loadSettings().StockCodes
  272. if query := c.Query("codes"); query != "" {
  273. codes = nil
  274. for _, raw := range strings.Split(query, ",") {
  275. if code := normalizeStockCode(raw); code != "" {
  276. codes = append(codes, code)
  277. }
  278. }
  279. }
  280. quotes, err := fetchStockQuotes(codes)
  281. if err != nil {
  282. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  283. return
  284. }
  285. c.JSON(http.StatusOK, gin.H{"codes": codes, "quotes": quotes})
  286. }
  287. // handleScreenshot 截取屏幕并返回 PNG 数据 URI。
  288. // 支持 ?display=N 指定显示器(0 起);缺省或 -1 截取全部屏幕。
  289. func handleScreenshot(c *gin.Context) {
  290. idx := -1
  291. if v := c.Query("display"); v != "" {
  292. if n, err := strconv.Atoi(v); err == nil {
  293. idx = n
  294. }
  295. }
  296. dataURI, err := captureScreen(idx)
  297. if err != nil {
  298. c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  299. return
  300. }
  301. c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
  302. }
  303. // ------------------------------------------------------------------------
  304. // 文件上传 / 下载(需密码)
  305. //
  306. // 上传:POST /api/upload 表单字段 password(密码)、file(文件)。
  307. // 密码正确才保存到 uploadDir 目录;返回文件 URL 供二维码展示。
  308. // 下载:GET /api/download/:filename?password=xxx 校验密码后返回文件内容。
  309. // ------------------------------------------------------------------------
  310. // uploadDirPath 返回上传保存目录:配置为空时回退到用户配置目录下 uploads/。
  311. func uploadDirPath() string {
  312. dir := strings.TrimSpace(loadSettings().UploadDir)
  313. if dir != "" {
  314. return dir
  315. }
  316. cfg, err := os.UserConfigDir()
  317. if err != nil {
  318. return "." // 兜底:当前目录
  319. }
  320. return filepath.Join(cfg, "TengTuToolbox", "uploads")
  321. }
  322. // checkUploadPassword 校验上传密码(常数时间比较,防时序侧信道)。
  323. // 未设置密码时允许访问(与「空密码=不启用」语义一致)。
  324. func checkUploadPassword(pwd string) bool {
  325. expected := loadSettings().UploadPassword
  326. if strings.TrimSpace(expected) == "" {
  327. return true // 未设置密码:默认放行(用户未启用该功能)
  328. }
  329. return subtle.ConstantTimeCompare([]byte(pwd), []byte(expected)) == 1
  330. }
  331. // handleUpload 处理文件上传:密码正确才落盘,返回下载 URL 与文件信息。
  332. func handleUpload(c *gin.Context) {
  333. // 1) 校验密码(表单字段 password)
  334. pwd := c.PostForm("password")
  335. if !checkUploadPassword(pwd) {
  336. c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
  337. return
  338. }
  339. // 2) 读取上传文件
  340. fileHeader, err := c.FormFile("file")
  341. if err != nil {
  342. c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件字段 file"})
  343. return
  344. }
  345. if fileHeader.Size <= 0 {
  346. c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
  347. return
  348. }
  349. // 3) 校验文件大小:上限由配置「上传大小上限」决定(默认 512MB)
  350. maxMB := loadSettings().UploadMaxSize
  351. if maxMB <= 0 {
  352. maxMB = 512
  353. }
  354. maxUploadSize := int64(maxMB) << 20 // MB -> 字节
  355. if fileHeader.Size > maxUploadSize {
  356. c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("文件过大,上限 %dMB", maxMB)})
  357. return
  358. }
  359. // 4) 构造保存路径:仅用文件名(去路径),避免路径穿越
  360. dir := uploadDirPath()
  361. if err := os.MkdirAll(dir, 0o755); err != nil {
  362. c.JSON(http.StatusInternalServerError, gin.H{"error": "无法创建上传目录: " + err.Error()})
  363. return
  364. }
  365. origBase := filepath.Base(strings.ReplaceAll(fileHeader.Filename, "\\", "/"))
  366. if origBase == "." || origBase == "" {
  367. origBase = "upload.bin"
  368. }
  369. // 5) 落盘:存储文件名用「时间戳+随机数+原扩展名」,不可推测且天然唯一,避免覆盖与遍历。
  370. // zip 内条目保留原文件名,下载者解压后仍能看到原始文件名。
  371. storedName := newStoredName(origBase)
  372. final := filepath.Join(dir, storedName)
  373. if err := c.SaveUploadedFile(fileHeader, final); err != nil {
  374. c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败: " + err.Error()})
  375. return
  376. }
  377. // 6) 安全处理:把上传的原文件压缩为 zip(zip 内保留原文件名,文件名同样用时间戳+随机数),
  378. // 压缩成功后删除原文件,之后仅提供 zip 下载,避免可执行文件等被直接下载。
  379. zipName := newStoredName(origBase + ".zip")
  380. zipPath := filepath.Join(dir, zipName)
  381. if err := zipFile(final, zipPath, origBase); err != nil {
  382. // 压缩失败:删除原文件并报错,避免遗留未压缩文件
  383. os.Remove(final)
  384. c.JSON(http.StatusInternalServerError, gin.H{"error": "压缩文件失败: " + err.Error()})
  385. return
  386. }
  387. os.Remove(final) // 压缩成功,删除原文件
  388. // 7) 返回下载 URL(指向 zip,相对路径,前端拼完整地址)与文件信息
  389. savedName := filepath.Base(zipPath)
  390. downloadURL := "/api/download/" + url.PathEscape(savedName)
  391. fmt.Fprintf(apiLogRing, "[SYS] 文件已上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, fileHeader.Size)
  392. c.JSON(http.StatusOK, gin.H{
  393. "ok": true,
  394. "filename": origBase,
  395. "size": fileHeader.Size,
  396. "downloadUrl": downloadURL,
  397. })
  398. }
  399. // newStoredName 生成存储文件名:时间戳_6位随机数+原扩展名。
  400. // 扩展名做白名单过滤(仅保留字母数字,最长 10),防止可疑后缀。
  401. func newStoredName(orig string) string {
  402. ext := filepath.Ext(orig)
  403. ext = strings.Map(func(r rune) rune {
  404. if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' {
  405. return r
  406. }
  407. return -1
  408. }, ext)
  409. if len(ext) > 10 {
  410. ext = ext[:10]
  411. }
  412. ts := time.Now().Format("20060102_150405")
  413. rnd := fmt.Sprintf("%06d", cryptoRandInt(1000000))
  414. return fmt.Sprintf("%s_%s%s", ts, rnd, ext)
  415. }
  416. // cryptoRandInt 返回 [0, n) 的随机整数(crypto/rand),失败时回退 time 纳秒。
  417. func cryptoRandInt(n int) int {
  418. var b [8]byte
  419. if _, err := rand.Read(b[:]); err != nil {
  420. return int(time.Now().UnixNano() % int64(n))
  421. }
  422. v := int64(binary.BigEndian.Uint64(b[:]))
  423. if v < 0 {
  424. v = -v
  425. }
  426. return int(v % int64(n))
  427. }
  428. // zipFile 把 src 文件压缩为 dst zip,zip 内条目名用 name(一般取原文件名)。
  429. func zipFile(src, dst, name string) error {
  430. in, err := os.Open(src)
  431. if err != nil {
  432. return err
  433. }
  434. defer in.Close()
  435. out, err := os.Create(dst)
  436. if err != nil {
  437. return err
  438. }
  439. defer out.Close()
  440. zw := zip.NewWriter(out)
  441. defer zw.Close()
  442. w, err := zw.Create(name)
  443. if err != nil {
  444. return err
  445. }
  446. _, err = io.Copy(w, in)
  447. return err
  448. }
  449. // handleDownload 处理文件下载:密码正确才返回文件内容。
  450. func handleDownload(c *gin.Context) {
  451. if !checkUploadPassword(c.Query("password")) {
  452. c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
  453. return
  454. }
  455. name := filepath.Base(strings.ReplaceAll(c.Param("filename"), "\\", "/"))
  456. if name == "." || name == "" {
  457. c.JSON(http.StatusBadRequest, gin.H{"error": "文件名无效"})
  458. return
  459. }
  460. path := filepath.Join(uploadDirPath(), name)
  461. if _, err := os.Stat(path); err != nil {
  462. c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
  463. return
  464. }
  465. c.FileAttachment(path, name)
  466. }
  467. // ------------------------------------------------------------------------
  468. // 客户端上传页:手机/其他设备在浏览器中访问 http://<服务地址>/upload,
  469. // 输入密码并选择文件即可上传到本机,上传成功显示下载二维码。
  470. // ------------------------------------------------------------------------
  471. const clientUploadHTML = `<!DOCTYPE html>
  472. <html lang="zh-CN">
  473. <head>
  474. <meta charset="UTF-8">
  475. <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  476. <title>腾图工具箱 · 文件上传</title>
  477. <style>
  478. * { box-sizing: border-box; margin: 0; padding: 0; }
  479. body {
  480. min-height: 100vh; display: flex; align-items: center; justify-content: center;
  481. font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
  482. background: linear-gradient(160deg, #0b0e1d 0%, #1a1030 100%); color: #e9ecf5; padding: 20px;
  483. }
  484. .card {
  485. width: 100%; max-width: 400px; background: rgba(255,255,255,0.05);
  486. border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 26px 24px;
  487. box-shadow: 0 10px 40px rgba(0,0,0,0.45); backdrop-filter: blur(6px);
  488. }
  489. h1 { font-size: 20px; text-align: center; margin-bottom: 6px; }
  490. .sub { text-align: center; color: #8b93a7; font-size: 13px; margin-bottom: 20px; }
  491. label { display: block; font-size: 13px; color: #b8bfd0; margin: 14px 0 6px; }
  492. input[type=password], input[type=file] {
  493. width: 100%; padding: 11px 12px; border-radius: 10px;
  494. border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06);
  495. color: #e9ecf5; font-size: 15px;
  496. }
  497. input[type=file] { color: #8b93a7; padding: 8px; }
  498. button {
  499. width: 100%; margin-top: 20px; padding: 12px; border: 0; border-radius: 10px;
  500. background: linear-gradient(135deg, #ff5a4d, #ff2d72); color: #fff; font-size: 16px; font-weight: 600;
  501. cursor: pointer;
  502. }
  503. button:disabled { opacity: 0.6; cursor: default; }
  504. .progress { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.12); margin-top: 16px; overflow: hidden; display: none; }
  505. .progress .bar { height: 100%; width: 0; background: linear-gradient(90deg, #ff5a4d, #ff2d72); transition: width .15s; }
  506. .result { margin-top: 18px; text-align: center; display: none; }
  507. .result img { width: 200px; height: 200px; border-radius: 12px; background: #fff; padding: 8px; margin-top: 14px; }
  508. .result .fname { margin-top: 10px; font-size: 14px; word-break: break-all; }
  509. .result .dllink {
  510. display: inline-block; margin-top: 8px; padding: 9px 18px; border-radius: 8px;
  511. background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.18);
  512. color: #4f8cff; font-size: 14px; text-decoration: none; word-break: break-all; max-width: 100%;
  513. }
  514. .result .dllink:hover { background: rgba(255,255,255,0.16); }
  515. .msg { margin-top: 12px; font-size: 13px; text-align: center; color: #ff9a9a; min-height: 18px; }
  516. </style>
  517. </head>
  518. <body>
  519. <div class="card">
  520. <h1>文件上传</h1>
  521. <div class="sub">腾图工具箱 · 客户端上传</div>
  522. <label>上传密码</label>
  523. <input type="password" id="pwd" placeholder="请输入上传密码" autocomplete="off">
  524. <label>选择文件</label>
  525. <input type="file" id="file">
  526. <button id="btn">上传</button>
  527. <div class="progress"><div class="bar" id="bar"></div></div>
  528. <div class="msg" id="msg"></div>
  529. <div class="result" id="result">
  530. <div class="fname" id="fname"></div>
  531. <a class="dllink" id="dllink" href="#" target="_blank" rel="noopener"></a>
  532. <img id="qr" alt="下载二维码">
  533. </div>
  534. </div>
  535. <script>
  536. var pwd = document.getElementById('pwd');
  537. var file = document.getElementById('file');
  538. var btn = document.getElementById('btn');
  539. var bar = document.getElementById('bar');
  540. var msg = document.getElementById('msg');
  541. var result = document.getElementById('result');
  542. var qr = document.getElementById('qr');
  543. var fname = document.getElementById('fname');
  544. var dllink = document.getElementById('dllink');
  545. function showMsg(t, err) { msg.textContent = t; msg.style.color = err ? '#ff9a9a' : '#3ddc84'; }
  546. btn.addEventListener('click', function () {
  547. var f = file.files && file.files[0];
  548. if (!f) { showMsg('请先选择文件', true); return; }
  549. btn.disabled = true; btn.textContent = '上传中…';
  550. result.style.display = 'none'; showMsg('', false);
  551. var progress = document.querySelector('.progress'); progress.style.display = 'block'; bar.style.width = '0%';
  552. var fd = new FormData(); fd.append('password', pwd.value.trim()); fd.append('file', f);
  553. var xhr = new XMLHttpRequest();
  554. xhr.open('POST', '/api/upload');
  555. xhr.upload.onprogress = function (e) { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
  556. xhr.onload = function () {
  557. btn.disabled = false; btn.textContent = '上传';
  558. var data = {};
  559. try { data = JSON.parse(xhr.responseText); } catch (_) {}
  560. if (xhr.status >= 200 && xhr.status < 300 && data.ok) {
  561. var dl = '/api/download/' + encodeURIComponent(data.filename) + '?password=' + encodeURIComponent(pwd.value.trim());
  562. var full = location.origin + dl;
  563. fname.textContent = data.filename + '(' + data.size + ' 字节)';
  564. dllink.href = full;
  565. dllink.textContent = full; // 下载地址
  566. qr.src = '/api/qr?text=' + encodeURIComponent(full) + '&size=256';
  567. result.style.display = 'block';
  568. showMsg('上传成功', false);
  569. } else {
  570. showMsg(data.error || ('上传失败(HTTP ' + xhr.status + ')'), true);
  571. }
  572. };
  573. xhr.onerror = function () { btn.disabled = false; btn.textContent = '上传'; showMsg('网络错误,上传失败', true); };
  574. xhr.send(fd);
  575. });
  576. </script>
  577. </body>
  578. </html>
  579. `
  580. // handleUploadPage 返回客户端上传页 HTML(供手机等设备访问)
  581. func handleUploadPage(c *gin.Context) {
  582. c.Header("Content-Type", "text/html; charset=utf-8")
  583. c.String(http.StatusOK, clientUploadHTML)
  584. }
  585. // handleQRImage 生成二维码图片:GET /api/qr?text=xxx&size=300
  586. // 返回真实 PNG 图片字节(供客户端上传页 <img src> 直接加载)。
  587. func handleQRImage(c *gin.Context) {
  588. text := c.Query("text")
  589. if text == "" {
  590. c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 text 参数"})
  591. return
  592. }
  593. size, _ := strconv.Atoi(c.Query("size"))
  594. if size <= 0 {
  595. size = 300
  596. }
  597. uri, err := GenerateQRCode(text, size)
  598. if err != nil {
  599. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  600. return
  601. }
  602. // GenerateQRCode 返回 data URI,剥离前缀后得到原始 PNG 字节
  603. const prefix = "data:image/png;base64,"
  604. if strings.HasPrefix(uri, prefix) {
  605. if raw, derr := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, prefix)); derr == nil {
  606. c.Data(http.StatusOK, "image/png", raw)
  607. return
  608. }
  609. }
  610. c.String(http.StatusOK, uri)
  611. }