From 98081456b71484ad4a0fbea8bb2eb9b824752bd0 Mon Sep 17 00:00:00 2001 From: huazhongmin Date: Thu, 25 Dec 2025 18:04:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8A=A9=E6=89=8B=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/__pycache__/config.cpython-312.pyc | Bin 0 -> 1354 bytes tools/app.py | 50 + tools/config.py | 33 + tools/models/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 366 bytes .../__pycache__/database.cpython-312.pyc | Bin 0 -> 6776 bytes tools/models/database.py | 118 +++ tools/requirements.txt | 14 + tools/routes/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 292 bytes tools/routes/__pycache__/api.cpython-312.pyc | Bin 0 -> 15247 bytes tools/routes/api.py | 324 +++++++ tools/services/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 306 bytes .../__pycache__/http_client.cpython-312.pyc | Bin 0 -> 7332 bytes tools/services/http_client.py | 194 ++++ tools/static/css/main.css | 894 ++++++++++++++++++ tools/static/js/app.js | 553 +++++++++++ tools/templates/index.html | 208 ++++ web_client/.env.example | 24 + web_client/DEPLOYMENT.md | 331 +++++++ web_client/QUICKSTART.md | 140 +++ web_client/README.md | 219 +++++ web_client/__pycache__/app.cpython-312.pyc | Bin 0 -> 5763 bytes web_client/app.py | 156 +++ web_client/requirements.txt | 14 + web_client/start.sh | 50 + web_client/static/css/style.css | 220 +++++ web_client/static/js/main.js | 324 +++++++ web_client/templates/index.html | 254 +++++ web_client/test_app.py | 159 ++++ 31 files changed, 4306 insertions(+) create mode 100644 tools/__pycache__/config.cpython-312.pyc create mode 100644 tools/app.py create mode 100644 tools/config.py create mode 100644 tools/models/__init__.py create mode 100644 tools/models/__pycache__/__init__.cpython-312.pyc create mode 100644 tools/models/__pycache__/database.cpython-312.pyc create mode 100644 tools/models/database.py create mode 100644 tools/requirements.txt create mode 100644 tools/routes/__init__.py create mode 100644 tools/routes/__pycache__/__init__.cpython-312.pyc create mode 100644 tools/routes/__pycache__/api.cpython-312.pyc create mode 100644 tools/routes/api.py create mode 100644 tools/services/__init__.py create mode 100644 tools/services/__pycache__/__init__.cpython-312.pyc create mode 100644 tools/services/__pycache__/http_client.cpython-312.pyc create mode 100644 tools/services/http_client.py create mode 100644 tools/static/css/main.css create mode 100644 tools/static/js/app.js create mode 100644 tools/templates/index.html create mode 100644 web_client/.env.example create mode 100644 web_client/DEPLOYMENT.md create mode 100644 web_client/QUICKSTART.md create mode 100644 web_client/README.md create mode 100644 web_client/__pycache__/app.cpython-312.pyc create mode 100644 web_client/app.py create mode 100644 web_client/requirements.txt create mode 100755 web_client/start.sh create mode 100644 web_client/static/css/style.css create mode 100644 web_client/static/js/main.js create mode 100644 web_client/templates/index.html create mode 100644 web_client/test_app.py diff --git a/tools/__pycache__/config.cpython-312.pyc b/tools/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e6b4847dbc3928e242b0f6f5066b0b3b79dab31 GIT binary patch literal 1354 zcmZ`(Pi)&%7=L!$#B-8%S;b0QR?1(IqRdbY1)+%IB`yhTlFfDy^~Jb-Z>`fL&TMB| zdkRv=s1Q^;Ksyj>Cz!Nqh>8OYm?m-O5{Qt3uh4dpI1G9~;>5F4FGJHT`TP6(o4|(~n zMm;x(j^}CF|B&jwZk1;tKlI%oJDyCF09PLIa$SvRNPXGk;Rry2*8%h|0~qjPGP6iV z=^BS?nD)gF_HKW*`|+heZhgz7CF%6&?uXZQRzKhU=KVX{AMM<{wsZ03-Y3f)So`Yt z4D*^=v*vYUbiSr8m6^8l;G9iC)l#J>Vbkb>W?G8AFx%}2fO}*90!P;X0y2R|O+rHU zA*$%ob3Yt_V)*~Q<3Z9J+6BTS2>^X^08+UA@PpW5|AR5azoAtMLAbfO9z>NqCkBXhe_SE1E_LdGxUn>n?si2tfx&? zRcqd+in>;@=@ch%N-87R1LBz}ZYr7Pv(t(bJjsc?QV=rs$bAV(;F715Y(ABl%p^G} zlh28EpTM6f@S>zhnJk|#NOmO4y`>1ecp7JUMa;;&-S33b8Bxj$(>RsP3n|g=b%G~3 zDVgqoj;8oYt}rDX#4*!A)4_*Q&7v6c9a{*i4yFEqx)#)#CQhFi~!HK-@=`oX|p zJ>Potxd!zJc7`8sPy<*W9jxbC$3}mqhK$F&hem^jv${sFBb*NG0MrbJH#?xtm@YAx z;INDrpxLA!Zl{+O@^I>d;Bpso9OJstHx8ohJRLUr;|@SWg$>2>Q@@bo|aKH`bL0Tylt{{hddRcZhL literal 0 HcmV?d00001 diff --git a/tools/app.py b/tools/app.py new file mode 100644 index 0000000..4eab8bd --- /dev/null +++ b/tools/app.py @@ -0,0 +1,50 @@ +""" +HTTP 接口测试工具 +Flask 应用入口 + +@author huazm +""" +import os +import sys + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template +from flask_cors import CORS +from config import Config +from models import db +from routes import api_bp + + +def create_app(): + """创建 Flask 应用""" + app = Flask(__name__) + app.config.from_object(Config) + + # 初始化扩展 + CORS(app) + db.init_app(app) + + # 注册蓝图 + app.register_blueprint(api_bp) + + # 创建数据库表 + with app.app_context(): + db.create_all() + + # 主页路由 + @app.route('/') + def index(): + return render_template('index.html') + + return app + + +# 创建应用实例 +app = create_app() + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5050, debug=True) + diff --git a/tools/config.py b/tools/config.py new file mode 100644 index 0000000..5452c22 --- /dev/null +++ b/tools/config.py @@ -0,0 +1,33 @@ +""" +配置文件 +HTTP 接口测试工具的配置项 + +@author huazm +""" +import os + +# 基础路径 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, 'data') + +# 确保数据目录存在 +os.makedirs(DATA_DIR, exist_ok=True) + + +class Config: + """基础配置类""" + SECRET_KEY = os.environ.get('SECRET_KEY') or 'api-tester-secret-key-2024' + SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DATA_DIR, "api_tester.db")}' + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # HTTP 客户端配置 + REQUEST_TIMEOUT = 30 # 请求超时时间(秒) + MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 最大响应大小(10MB) + + # 历史记录配置 + MAX_HISTORY_RECORDS = 1000 # 最大历史记录数 + + # 批量请求配置 + MAX_BATCH_SIZE = 100 # 最大批量请求数 + DEFAULT_BATCH_INTERVAL = 100 # 默认请求间隔(毫秒) + diff --git a/tools/models/__init__.py b/tools/models/__init__.py new file mode 100644 index 0000000..89e3d9e --- /dev/null +++ b/tools/models/__init__.py @@ -0,0 +1,9 @@ +""" +数据模型模块 + +@author huazm +""" +from .database import db, RequestHistory, Collection, Environment + +__all__ = ['db', 'RequestHistory', 'Collection', 'Environment'] + diff --git a/tools/models/__pycache__/__init__.cpython-312.pyc b/tools/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6a45d1f48419e43a5d152588644999a8dda1d2d GIT binary patch literal 366 zcmXw!ze__g5XY0e^am+*b`)IfV*UY9s}@}9Ah^6Gyf&eIkRQ*Nq)<0!agb8M|Uu-Pm>Pz>%mSISC5O!`QrZe{pKR_dPc}@=H1woYh$0CDe?h> zuY)VZ2i3~4IQ7E1!`wPkPAjr+q!dG!8(p<}dXkq$E1}&{r%^Q+<7UWM)#RDW2ALIQ z5;m1Mq83pPh_*~h#vZ^jl;EdNPlD6ZczkU1NadOqBGcZ{Zc#{aASObZg7%b&XZzj? zuc$LdTB-~eAu*Qg+%dLUOsnQrXnHBO%XZivU$LtJp9zHU3t@=A34nX>1$1A@&UvG3 Gh2bw4S8bjE literal 0 HcmV?d00001 diff --git a/tools/models/__pycache__/database.cpython-312.pyc b/tools/models/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10136a1d2f6344832545b2a4271357fadd12ae87 GIT binary patch literal 6776 zcmeI1Z*1Gf6~L+g5@lJ6ti+b>*s0sbR?^0s=WhAa4T+Q3`7;}9;;yx@PM}5FmLrME zkxHr{8?-gqVr;D~WQDA?nA2jw9N10Mepu50#XfE5FkoAt0nrEy$k57-zol^UWuNvQ zDblh{CXF);D=-3ncYJ#9cs$;{_q+3-JRX{YOS1RJe($BIPw+;)Y?aE=JXG=&ONA-c z#G2!#gehz?VcilpCoEx$i8521Wv*f#wwkFvinX4jSQ}>@m)lpHRLVJvQikoar5!9C z53qE|mQJvAJ;2hLp+oNUOYj7G;q|KvuU;u%zql~_R{2*yD_{BLryowx<)5D~PtDza z_k)G^eq4U-C*{faKgs@R;TONXou4fK`a`IH_S(DUw_hnwPA~p+^0Rj)6la>=%cR9I zo@^OQGiMU?BECY%Bsp0|giCNqaS<5f-a-pCy0$u>7;JsIwtihLj>M`9TF{x?pv8}}>H=-!Diz$4W_|tm=C!tJ)?v+W z)*hGh6P6*%J^T*0Kbl{7_3cluT`#|rFQ30Q5UQ2TF;=oCIEdt|WJ!~_ zPcei^2$FS}XETx$v z3m)~M5xK`c?N)swx6+A76na5gu~oX#fe0f?u9(1&@Fc;AFqN$W7atLD9&h*F_DyKl61m$aY2 z!&C5-c0T(%?{)8VV5YUudNbH{BiL08c7MLv>!a^bUKf3*nHn%1Gc7BYpl{SbXtU&u zM3PK`i$o+k5`i~O$FW`$iJVL`@yaibNQC91p!P%}Fa~k1qBpa{l6^lPPbZR+<8V^s zMmZu+0m*(`B(dbEJQXDCAosGUHzCMl&YUD{2TR}*+-Z;;Q%13q{fH^h@ zNJ20UN%piDP4cHnI~rqnzD$v)vA!Jzh89^Mf(TtO1I|%@x3-l6PiFgXx!jY%iQrUM zes^y7Z1*+sLa^vMknJgXypux{LsKN5$z?A2FaBUQePO8Rc_!OiqH88Y6QQY|d|$5b z5_cg~q@T*}FV*@cV-vAy^Nge5DAsPt_AR$?I(NG0X$4QWXL8rXuBl`B7jiEY-J7!q zNX0$L` z3~bHzm+FH`kAGADR(-KP^jB-3q&Xfb94Y$Svqwwb4U?IP%(Q=|q0mtDwq*}NQ(zU$ z1*f}a_7wJ9b$;M|&pQG^9wwgOlm3I$hq8snf&#?tz141xFa`cI>u-uSrGKB{Hd7y`eTzcCKp?_G^? z@P1yK3~_LWwuZC{*D&6t-Ds70TEoG{M{mvG8E$R6)x_4w$ms!)pdpyYxhQ79G_r=a zgRD@dJF5LI3=(9uMHWOaK@=ly6o_90eM4q~&?PyctrTu3R}dxsgpqvd5C11h7zXuv zD57k9c%n?2SQB`)sLxw2Wj@u>KY&lY6+VSBWFg4Y0~HLEtVjuxo8^QkiKS##w2$y{ zz&1ldBtd9^AU%^N5Rz47Mg_73t1e{{A!hm%%Mou(rI@*RwDy;eJO0x7$4-R2FhZVR zg*+VVDEw)c269Dd=k%O(ilCf&uJ@3w}xA`QL`?t*Ivl5xWR$VP%;K5MRL|% zkz|qHan&uo58}UlY$GQWo#UKw#DrDXT;(1PJZzPO0;QHvY|8;(a-CvG4ELadNb6CgvPzM z+m4ag4w0BvBC)2ZMB>0BMPm649X)VB0jC(`JJ3&}7iIMV>|r!Ufs0-^4+6Kx&80xw z7Y(rF;!Cs7U+cyYySI8%+g@ld`r4{DwA%{XX1OU>(bo;ywINB{aXQstg#8)_xsb8zE2!mcCE5D%Y)0Ae%Xve7x0MM+az3k^YkZ3gf+K`9N;nGQ zj>Tj$eQvgytoLdtQ_DZ7_J7*EuY50SzH9F=(XeTMG56wK3aWR%YeI!T-;iri6@E~( OXT9_9QQP6U24x4R;)r8wXvI+SS&d$yU5U#SY(@Ly- z2;X2ULHz5K!^X<2=oB;GAH&SUa7a+B)w~p=IG=j*U&$YstwbmPo9UD#3`Q zV_c<^te6XHF+kq#Z(lF&Uaqg6FQ0^K{jmEFqOSO~$H)s|^=)0tcj$23jMU8I9O0?AQx(%N08YB50KjBgvy05esx-Em_ zK~b-@bkyqLxuXE06&_&IOuE6A(kw{6ZTrXjv)oc_A1TK8Gs$rFMG#(oTv$BcILqxI FT0fYPQcVB= literal 0 HcmV?d00001 diff --git a/tools/routes/__pycache__/api.cpython-312.pyc b/tools/routes/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a79369fb793237f368992bcac1325f0ad02f0b6 GIT binary patch literal 15247 zcmeHOZ*&vcb)S(&(&+y`%a$$wWwEhfZ36~lzzYjD1`JC!i+4l3u#VOvV+q-EW+X6< z+-kR*tR2YOZb@uTXwf#?6LTQN+x8UFG|l!TJ>8}sYGu4fVnR={4c>(hJ;xk4r%C#y z_r1}KMp_97Cm;GD?^ti%%)9r!H}B26_jm7o{&%z4KtSr(y%0#RC5T_48!2frg$EB+ z1TjZ2L?6MBj4DL-k@%|WQ^8doQV&yo)Uc*cGpy~?l8A;1>4xb(dRX75A2#$EhK+qj z{9F?<4V(MSB%vbw6r=T97~M7cs#@%)&k9e?S2LaV+5FbvGtyA9S4PhmPLkJ*S7A&j zF{ZZ&na5Ft$DGGwQSdm6@K_mJPCs@9Pe~CT2jk4)DN*paitxDdcuEyKrA2tk^2X*? z@RSwh$(^@y1&_N3PetC?JPMxjB0QCOW2;i|R21dO9b2`6$5Vu-CT~q^6+D$icG!5;f!APo-WQDw zh1uGnsBe7Oa1VXAN4#o5zc&>1kFtT0h@fNrUyk}YCw@Ja@%e!O z7YVax1w&^z6!H&50^t$CxNqcifDMlf`$r;PQZO8dL`FM90k{*?zR|!q)dD%Hh_a#n zQPw{g7#pWsq>iW;KXrR_IC z#0b_NBZ89PP!KWX)l3_lEuxbu7!{*ts7orTj?oONjzTL1ThKB(Y12=TMQG$+CR9Zz zXsNFWbrA}^OhFZ)Fvt{|A{0i(B#%{FgvKnlqANmSktyhiUYfI*I)X(ux|jN3OZ7y= zEKvk4QYm|lspYoRingeyYKxNFq7-e>Pt{g-J?IGf>Tn7=rBe19BV($VI!47bF>Op2 zqpw-7(qip|zR3L97`rq>GbC$>xTMy^wpf>9&Pt`a>@_=QBP8R9QIDmU=LOSe#`#z- zc?K|-zTmene{r$G^>Q2R+Q+=pWB8?&eZ*Lt5>1h@lt}g0Tvtt*6*5HJ(!2&Mq*C@8 ztjd<9QcQkll6VYvjl`3^N-~KgVuoOC_PJDw>6y|FxXH?*n7r?g63-AM5g;biUnFM8 z^YdeHJws-mQTSOb=NXNkeMx-gEsHv!;g#vxd+|4vib7C>9PW;yZHhF<^vs=q`gZNh zFCD(35_DX2V8G9Dg2vCXVfNk($Vx#g3T8pQe_yYl8}j=YKg$W)Q6K9Y<^<~XFmqO* zQQmuY)Gts-ZwNF}H2B_tN}b`6!N5r`&7v=#Mca!lLm65Owj44+j~eKYpceFqLo6C` zaR74*M$UKIpV2?PM+5^G@kOFsXPEH|G!tcgNEdlcMY~|DQD|;2=I0VYEe?ZiZCeA;?^k)AA6v9+HL7;sc9qZd&GsddW$yRz3L`t)7xWjjLn$mBOmG3-geZco#knp^VWvV7rN$pW_#w(ESIdiN!{|c zT-Y+VV|K^<;IgxRu{UjZrAuqz?^f%sOVnka zKd=Ari_3ezaH;ol-^IQK?RCQy!{T@U(=HEwQ3LPV@UYzM zG)(FqRuX#Sv|-9HZJDynoLQ!8Zqo*IpQ6i?bonyvNmtij4qgmSYA3&vq&;_xj+wS| zyO*enU$6F)q`o{|>OQyse^(8p-ko-po$JQMs&qx=xkJC@zyc(G>~tO6PW*Vgt4B@z zq{h`_CjMYtjao)Y!Aw&itSWMC)YB{d;dui#kR$Lj*b_&$#X=lJ>HS5vx}w=kC6{z#`$mmOv)ZV0HM} z)4tF>25x%?WP}fQNW`|n??D%2hd>w30VY2IN(1JQ3@Rz19wmm5M#z!pT@3jxa*ia4 z^A!|vR`osg8S;vnMLO{wVm)$&WW8_&wWn&{D3C0NOp4lCb{kv-eaA`vh<|L9-8Ei| zKQT#6H+O`>1HKTqt68Q90s~hG*Ob1N_tKts&-~^A zTF^I3YsrhcTJrbxmTbYdWS@hs*xe|59x~;7v7PYX@o#hrv1Q-L-SzJN`7bYSy0P{B ztvAa*YVEvae|P_HShOl$4W3bCHzExK$RQGr_(FmygCsh`Q7{AbgT6or7XbsHRb?Ad zhNd6uOtuM1f;nfR1$D>|s*j5fdntAcu9v>7{t)P4b_e3_hKxfkB4vh`j_pCuQT9bt z_Z`R{rGX5~WgD;xT59b{mL1})hd%@z^!X+F^S>-{&mEXOFyFV};7gv3zmT>%r^lwo z<_!tZRE_ZicR-$7)+H_L7R-xdyyf}$eh_kLXIa`=aob)p^MyaFyzTZR(bvFxI(c{J zZI?TVW|DVz@vg4>Ms11q0iiYNRtc>}_W*`TfK{AP&@3_oa-)1H2lCPc6;rXz zF)Ap_$Si?^vw+?ycAOhWveh-bv08w1=~f@2Ngu< zIWH;3FWWd-k{|-1c}Z$Lr{zKx5D_C8{WXL9#b_e3rNbCwnqX~S!*JV0Olt>JCy4>o z33%HHZA@Fpi7Jg?R&~CF0$QNc1lNAHwHO+KNvVGZR3xT9LwL(`!lpZjF z8ugv@v%8=qen7Xq z87YUw5W649FfIEMYT7AU0+f$E$zh2ms6nM=&^-9kz*(jp41_?*VZ%qD!JLWsz(T=fM*)Wpa8HCy}{J19gv@>#Zx?`$C zMDO$6i9Odkmk^BK81K4mbmUa+y>=8=ZGtLu$@KB5<0*4h(p)t^w4mb68{*xCpWZQ5 zq)fF*Q*C_T$2xP`=9)9jnl8PTs%lAAwWO-rlU40EOZlqjdE1^z&8-}Y&B?0Gi-R{p z?}z?&Bw5wP+xAUrerd8#ADKFGX;;d#CF$9c@@!9fw)38yys0C;FKsWKtDdb+^e%RN zuy3ZCxA(+*GE%!?W2&L;XANzRV4_jw5+;~+if|G0;WJ**dSVSUu7n}luuOlKqWOx@?t5TLVNz0m)r6Fl) zNU*$ReSH5dqayLu%;CHl}EO8a)t8$Bt?m84uVCzq+}+sY@)R2l4@ znS=9@CFfe+SieNovqzw(af|%j41|z$f@gA~poBk`3s88Pk3|x;uoyB72+B(;_fyc| zQ1HsC6UE+g7E&#m1UJ9>>6L#_E@WnETW$a)QIfv1^cG$*oQ_B6ms zt>7mgy-p~!z^;j@3K@tB&W!65=1fj8A_q{YVkCRyvF$u5D3Z6fVhjZUPeDK>^Nz!& zmSKqs##R6vDcVP9IWGl~TQM(5U$NBkqd*au)iM-f0M-GZp%j3I0+1(zplml%4>F(u zpqv%LM**^6*g^qp(Te6VhAncWPBf1JFoZ(@2iTXPWvtG7uc+`Y!U)7`6K$wts21B$ z=(`m~;H#)K3YkES_=f%LaddA(*=y*bAvy|=~#}z>m8J8Q!E8*YAJ?N>irFq^U9o3G}6^nv+${iwyt- zc-wX*2Doi5o9>_L7u{-!LEhYip1d~oTFSgOXoH9FA!o3ZZyiJ`1(y&*?tqwgNeY!gUtkS7^)0h~II3tz4&5=xSHNYMbN zY?S1{0SIp@zeGlh;eZ~-htuPFD^jnMc2bdgIUH1^9?nom(y|DJ{5D1EjnW7p;AE8E zCRkAB6*P)veyJS@IGKv{B?n5MvM-tc3Hp*lu0_UTmD(s$Z+lF=U8;Y|caiy}b{;Wr z4vFR|`;z&kc2Hl}oN`c)jL5nvh(ujpDjdX>+5$B6Xzl`$yNUO>2)0LTTeEQwRl|Ma?LUjuNsS%loY+w|H zWklhPEkDGIV{AvVK{mDmJw$Fj7O6HciG3$Ih?wCn&!Wdz_+x%Fk9je=yZ{S3*f{0?jj%4AH=ZbG-4QHGU{N_6iT6naGIinXG+pF+v7G2pxjKQ5fL zQ42*s&G;ghw%ZYk${-sT}=>t;-W?nw`xpeEc8}0A6-#ob7+I@kVGt3(1w_I+&*q%7Z zdzzPREgyEJH*LM)e&2nwad}hc`N;J6)cE|`%Z(Qs6YUU*S++KPSdue5RU#e+$vS~Np zv?uA@%NskFs7}#NaVZhBY~%jHwq-tR+agu8f^A!IkBe5VSkAR-*#O!R2r?k6HVf%6 z!b7-Av4gPvgm#c*(tZWXIVP<=W71|f75hg(^AVzxty)K==+sEr>y!5S#Ou7>8}GSg zbUlex8+>AW%iQA69uO;Ha9x6NJ&nO7iC6GqXFUvXDhuE`setP!UL=DZ=qi&WZW*qF z`KA=p8MsaYTvtbtS5@>fe)`V!mB0LxmFamD`@#XOj2zUks1p&1BTu*jS*rw-aux*A zpR-&x1f-P&?3=*ezpJCvP* zM*kar+pItxSd->SKV~JCXoB66X-my)F z0f{K@XnLrljM{kTs)eA9Z%@2AahrCEXD(87ZIZ5iZ$oO$w&a>^H(g70yO-(bGp06QGG0zbxYWkzsU%m3`)e}kQPTttDM0JR2z9vU-%X+>AF95g^3M>2ukRDGX%715P zHcY#i3R%;I#61$p8IA%DY=+EwGvU7kG%>OtXwu?^X_r>CtA-%|8VJ}wvfZEzN2NZqdJ~1y^ZG<^KQA!`MW5hr?jlY+5217o3b~&q z;D8ei!E$3B6WlUJcspc3Lpl!E3GIX~rYjWOlVlVMt|NCj1nK4I4ra%hM1@9telPX7 znKCOzO~+`qE=E6krl5BVY8%Z88U;h5=me`_tkOKiNRX8_ynXI|_dB2b#Z1n|z^)GT ztsa4cLV3;$)ZTbC+5pG74X>^{zUIX4##dj1>v8VH2Cw)4pAf(0iQnx+f(8!RMg4*SpR~r@9D+&Mn~yT_^wc}37nH3*BESe)oPg7~PIuaB zPdi;G_N~{T6RHnwgxxu(pVco_?7VrLxAnvi-qP99?z+p?i`E5_cdv(|pV2AvLoHEK zI=6jx`}~Q8!@P5Q{O~QEGwoTI@-!tqO$$EWvoYmqOM2RPPdjjoC22RXl)BN0)1_L} zw5un6XSOFzRr3Rv z0~Z6|KJ_!x`c)m_tb1r893?2AzvMamVTiZC3U;o}o-ThTRqjoedl%~X@@Jv<(^HnT z(LU1%Cza=;KQpdP*VIFU?GTdqrO_UL0Shi~ouKRQXTk&h{rG4%`%Sban!%8dJH;Zg zDk4%O9pLOd93Bse=efo3C)(&(dkZw2xIP_#uc;3bIR97fE^^s0r1xtGKv&L?uxT zB}x-CqVnEXIY=EMTEB`)tLr6_U}9@x9U@tOUsWcOY*OR#9oSIZJXn zF@#83?yJhlE)sRMaTOO=+vUFM7wLs@M72X6&rWeX6;eat1x?~irXj7^7qkPV>~O*h z+Qb+l*@%Yo9O`Nl8qRa_aF#4BHyduc5zXHFDueh|o#ZMiu2xH3jU=*T){||SAwy}^ UT}L|RkFFAMN%THM7yM!TAN(}gD*ylh literal 0 HcmV?d00001 diff --git a/tools/routes/api.py b/tools/routes/api.py new file mode 100644 index 0000000..131c150 --- /dev/null +++ b/tools/routes/api.py @@ -0,0 +1,324 @@ +""" +API 路由 +处理 HTTP 请求、历史记录、收藏夹等 API + +@author huazm +""" +import json +import re +from flask import Blueprint, request, jsonify +from models import db, RequestHistory, Collection, Environment +from services import HttpClient + +api_bp = Blueprint('api', __name__, url_prefix='/api') +http_client = HttpClient() + + +@api_bp.route('/request', methods=['POST']) +def send_request(): + """发送 HTTP 请求""" + data = request.get_json() + + if not data or not data.get('url'): + return jsonify({'success': False, 'error': '请提供 URL'}), 400 + + # 解析请求参数 + method = data.get('method', 'GET') + url = data.get('url') + headers = data.get('headers') + params = data.get('params') + body = data.get('body') + body_type = data.get('bodyType', 'json') + auth_type = data.get('authType') + auth_config = data.get('authConfig') + + # 解析 headers 和 params(如果是字符串) + if isinstance(headers, str): + try: + headers = json.loads(headers) + except: + headers = {} + + if isinstance(params, str): + try: + params = json.loads(params) + except: + params = {} + + if isinstance(auth_config, str): + try: + auth_config = json.loads(auth_config) + except: + auth_config = {} + + # 发送请求 + result = http_client.send_request( + method=method, + url=url, + headers=headers, + params=params, + body=body, + body_type=body_type, + auth_type=auth_type, + auth_config=auth_config + ) + + # 保存到历史记录 + if data.get('saveHistory', True): + history = RequestHistory( + method=method, + url=url, + headers=json.dumps(headers) if headers else None, + params=json.dumps(params) if params else None, + body=body, + body_type=body_type, + auth_type=auth_type, + auth_config=json.dumps(auth_config) if auth_config else None, + response_body=result.get('body'), + response_headers=json.dumps(result.get('headers')) if result.get('headers') else None, + status_code=result.get('statusCode'), + duration=result.get('duration') + ) + db.session.add(history) + db.session.commit() + result['historyId'] = history.id + + return jsonify(result) + + +@api_bp.route('/batch', methods=['POST']) +def batch_request(): + """批量发送请求""" + data = request.get_json() + + if not data or not data.get('requests'): + return jsonify({'success': False, 'error': '请提供请求列表'}), 400 + + requests_list = data.get('requests', []) + interval = data.get('interval', 100) + + results = http_client.batch_request(requests_list, interval) + + return jsonify({ + 'success': True, + 'results': results, + 'total': len(results), + 'successCount': sum(1 for r in results if r.get('success')), + 'failCount': sum(1 for r in results if not r.get('success')) + }) + + +@api_bp.route('/history', methods=['GET']) +def get_history(): + """获取历史记录""" + page = request.args.get('page', 1, type=int) + size = request.args.get('size', 20, type=int) + search = request.args.get('search', '') + method = request.args.get('method', '') + + query = RequestHistory.query + + if search: + query = query.filter(RequestHistory.url.contains(search)) + if method: + query = query.filter(RequestHistory.method == method.upper()) + + query = query.order_by(RequestHistory.created_at.desc()) + pagination = query.paginate(page=page, per_page=size, error_out=False) + + return jsonify({ + 'success': True, + 'data': [h.to_dict() for h in pagination.items], + 'total': pagination.total, + 'page': page, + 'size': size + }) + + +@api_bp.route('/history/', methods=['DELETE']) +def delete_history(id): + """删除历史记录""" + history = RequestHistory.query.get(id) + if not history: + return jsonify({'success': False, 'error': '记录不存在'}), 404 + + db.session.delete(history) + db.session.commit() + return jsonify({'success': True}) + + +@api_bp.route('/history/clear', methods=['DELETE']) +def clear_history(): + """清空历史记录""" + RequestHistory.query.delete() + db.session.commit() + return jsonify({'success': True}) + + +# ==================== 收藏夹 API ==================== + +@api_bp.route('/collections', methods=['GET']) +def get_collections(): + """获取收藏夹列表""" + folder = request.args.get('folder', '') + search = request.args.get('search', '') + + query = Collection.query + + if folder: + query = query.filter(Collection.folder == folder) + if search: + query = query.filter( + (Collection.name.contains(search)) | + (Collection.url.contains(search)) + ) + + collections = query.order_by(Collection.updated_at.desc()).all() + + return jsonify({ + 'success': True, + 'data': [c.to_dict() for c in collections] + }) + + +@api_bp.route('/collections', methods=['POST']) +def save_collection(): + """保存到收藏夹""" + data = request.get_json() + + if not data or not data.get('name') or not data.get('url'): + return jsonify({'success': False, 'error': '请提供名称和 URL'}), 400 + + collection = Collection( + name=data.get('name'), + description=data.get('description'), + folder=data.get('folder'), + method=data.get('method', 'GET'), + url=data.get('url'), + headers=json.dumps(data.get('headers')) if data.get('headers') else None, + params=json.dumps(data.get('params')) if data.get('params') else None, + body=data.get('body'), + body_type=data.get('bodyType'), + auth_type=data.get('authType'), + auth_config=json.dumps(data.get('authConfig')) if data.get('authConfig') else None, + tags=json.dumps(data.get('tags')) if data.get('tags') else None + ) + + db.session.add(collection) + db.session.commit() + + return jsonify({'success': True, 'data': collection.to_dict()}) + + +@api_bp.route('/collections/', methods=['DELETE']) +def delete_collection(id): + """删除收藏""" + collection = Collection.query.get(id) + if not collection: + return jsonify({'success': False, 'error': '收藏不存在'}), 404 + + db.session.delete(collection) + db.session.commit() + return jsonify({'success': True}) + + +# ==================== 环境变量 API ==================== + +@api_bp.route('/environments', methods=['GET']) +def get_environments(): + """获取环境变量列表""" + environments = Environment.query.all() + return jsonify({ + 'success': True, + 'data': [e.to_dict() for e in environments] + }) + + +@api_bp.route('/environments', methods=['POST']) +def save_environment(): + """保存环境变量""" + data = request.get_json() + + if not data or not data.get('name'): + return jsonify({'success': False, 'error': '请提供环境名称'}), 400 + + env = Environment( + name=data.get('name'), + variables=json.dumps(data.get('variables', {})), + is_active=data.get('isActive', False) + ) + + # 如果设为激活,取消其他环境的激活状态 + if env.is_active: + Environment.query.update({Environment.is_active: False}) + + db.session.add(env) + db.session.commit() + + return jsonify({'success': True, 'data': env.to_dict()}) + + +@api_bp.route('/environments//activate', methods=['POST']) +def activate_environment(id): + """激活环境""" + env = Environment.query.get(id) + if not env: + return jsonify({'success': False, 'error': '环境不存在'}), 404 + + Environment.query.update({Environment.is_active: False}) + env.is_active = True + db.session.commit() + + return jsonify({'success': True}) + + +# ==================== 导入功能 API ==================== + +@api_bp.route('/import/curl', methods=['POST']) +def import_curl(): + """导入 cURL 命令""" + data = request.get_json() + curl_command = data.get('curl', '') + + if not curl_command: + return jsonify({'success': False, 'error': '请提供 cURL 命令'}), 400 + + parsed = parse_curl(curl_command) + return jsonify({'success': True, 'data': parsed}) + + +def parse_curl(curl_command: str) -> dict: + """解析 cURL 命令""" + result = { + 'method': 'GET', + 'url': '', + 'headers': {}, + 'body': None + } + + # 提取 URL + url_match = re.search(r"curl\s+['\"]?([^'\"\s]+)['\"]?", curl_command) + if url_match: + result['url'] = url_match.group(1) + + # 提取方法 + method_match = re.search(r'-X\s+(\w+)', curl_command) + if method_match: + result['method'] = method_match.group(1).upper() + + # 提取 headers + header_matches = re.findall(r"-H\s+['\"]([^'\"]+)['\"]", curl_command) + for header in header_matches: + if ':' in header: + key, value = header.split(':', 1) + result['headers'][key.strip()] = value.strip() + + # 提取 body + body_match = re.search(r"(?:-d|--data|--data-raw)\s+['\"]([^'\"]+)['\"]", curl_command) + if body_match: + result['body'] = body_match.group(1) + if result['method'] == 'GET': + result['method'] = 'POST' + + return result + diff --git a/tools/services/__init__.py b/tools/services/__init__.py new file mode 100644 index 0000000..458258f --- /dev/null +++ b/tools/services/__init__.py @@ -0,0 +1,9 @@ +""" +服务模块 + +@author huazm +""" +from .http_client import HttpClient + +__all__ = ['HttpClient'] + diff --git a/tools/services/__pycache__/__init__.cpython-312.pyc b/tools/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8677586ee54dbb6f72c06f3d0a62c6b47ed7416 GIT binary patch literal 306 zcmYLFJxc>Y5Z%2yP1ulDK@co#(q#XDh@yf`w6eKoIBu8R!hUhP8;G5q2-az|NTi46{UHmE;LV zES=&!ooAzsup13z^>E+bEmu#=_HjYTQ4xG)>b)uy*NVL3=osOKoO^Fiq~O}uX>47L zvZe>XObO5oswhJFuXa))CBQRUM>G1qq*spDj&@5cqo+z}YI&i9r^nVxe#&QD8cWZV z@xtgya6BmLjggMV!I>zzqX0q+4=}d#rhlLe3z8q!-5a_i-Bt`ADaQCS$#C{X5Z-?s MY~PM=dUZcaKgQ-*rvLx| literal 0 HcmV?d00001 diff --git a/tools/services/__pycache__/http_client.cpython-312.pyc b/tools/services/__pycache__/http_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..adfc8cecfe181ba6a11ac39a76aca94e49219f4e GIT binary patch literal 7332 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 Dict[str, Any]: + """ + 发送 HTTP 请求 + + Args: + method: 请求方法 (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) + url: 请求 URL + headers: 请求头 + params: URL 参数 + body: 请求体 + body_type: 请求体类型 (json, form, xml, raw) + auth_type: 认证类型 (bearer, basic, apikey, oauth2) + auth_config: 认证配置 + + Returns: + 包含响应信息的字典 + """ + start_time = time.time() + + # 初始化请求头 + request_headers = dict(headers) if headers else {} + + # 处理认证 + self._apply_auth(request_headers, auth_type, auth_config, params) + + # 处理请求体 + content = None + data = None + + if body and method.upper() not in ['GET', 'HEAD', 'OPTIONS']: + if body_type == 'json': + request_headers.setdefault('Content-Type', 'application/json') + content = body + elif body_type == 'form': + request_headers.setdefault('Content-Type', 'application/x-www-form-urlencoded') + try: + data = json.loads(body) + except: + data = body + elif body_type == 'xml': + request_headers.setdefault('Content-Type', 'application/xml') + content = body + else: + content = body + + try: + with httpx.Client(timeout=self.timeout, verify=False, follow_redirects=True) as client: + response = client.request( + method=method.upper(), + url=url, + headers=request_headers, + params=params, + content=content, + data=data + ) + + duration = (time.time() - start_time) * 1000 # 转换为毫秒 + + # 尝试解析响应体 + try: + response_body = response.text + except: + response_body = str(response.content) + + return { + 'success': True, + 'statusCode': response.status_code, + 'headers': dict(response.headers), + 'body': response_body, + 'duration': round(duration, 2), + 'size': len(response.content) + } + + except httpx.TimeoutException: + return { + 'success': False, + 'error': '请求超时', + 'duration': round((time.time() - start_time) * 1000, 2) + } + except httpx.ConnectError as e: + return { + 'success': False, + 'error': f'连接失败: {str(e)}', + 'duration': round((time.time() - start_time) * 1000, 2) + } + except Exception as e: + return { + 'success': False, + 'error': f'请求错误: {str(e)}', + 'duration': round((time.time() - start_time) * 1000, 2) + } + + def _apply_auth( + self, + headers: Dict[str, str], + auth_type: Optional[str], + auth_config: Optional[Dict[str, Any]], + params: Optional[Dict[str, str]] + ): + """应用认证配置到请求头或参数""" + if not auth_type or not auth_config: + return + + if auth_type == 'bearer': + token = auth_config.get('token', '') + headers['Authorization'] = f'Bearer {token}' + + elif auth_type == 'basic': + username = auth_config.get('username', '') + password = auth_config.get('password', '') + credentials = base64.b64encode(f'{username}:{password}'.encode()).decode() + headers['Authorization'] = f'Basic {credentials}' + + elif auth_type == 'apikey': + key = auth_config.get('key', '') + value = auth_config.get('value', '') + location = auth_config.get('location', 'header') # header 或 query + + if location == 'header': + headers[key] = value + elif location == 'query' and params is not None: + params[key] = value + + elif auth_type == 'oauth2': + token = auth_config.get('accessToken', '') + headers['Authorization'] = f'Bearer {token}' + + def batch_request( + self, + requests: List[Dict[str, Any]], + interval: int = 100 + ) -> List[Dict[str, Any]]: + """ + 批量发送请求 + + Args: + requests: 请求配置列表 + interval: 请求间隔(毫秒) + + Returns: + 响应结果列表 + """ + results = [] + + for i, req in enumerate(requests): + result = self.send_request( + method=req.get('method', 'GET'), + url=req.get('url', ''), + headers=req.get('headers'), + params=req.get('params'), + body=req.get('body'), + body_type=req.get('bodyType', 'json'), + auth_type=req.get('authType'), + auth_config=req.get('authConfig') + ) + result['index'] = i + results.append(result) + + # 请求间隔 + if interval > 0 and i < len(requests) - 1: + time.sleep(interval / 1000) + + return results + diff --git a/tools/static/css/main.css b/tools/static/css/main.css new file mode 100644 index 0000000..b6af25b --- /dev/null +++ b/tools/static/css/main.css @@ -0,0 +1,894 @@ +/** + * API Tester - 科技感未来主题样式 + * @author huazm + */ + +/* ==================== CSS 变量和主题 ==================== */ +:root { + /* 默认主题: 深蓝科技 */ + --bg-primary: #0a0e17; + --bg-secondary: #111827; + --bg-tertiary: #1f2937; + --bg-hover: #374151; + + --text-primary: #f3f4f6; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + + --accent-primary: #3b82f6; + --accent-secondary: #60a5fa; + --accent-glow: rgba(59, 130, 246, 0.5); + + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --info: #06b6d4; + + --border-color: #374151; + --border-glow: rgba(59, 130, 246, 0.3); + + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + --shadow-glow: 0 0 20px var(--accent-glow); + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 主题: 紫色星云 */ +[data-theme="nebula"] { + --bg-primary: #0f0a1a; + --bg-secondary: #1a1028; + --bg-tertiary: #2d1f4a; + --accent-primary: #a855f7; + --accent-secondary: #c084fc; + --accent-glow: rgba(168, 85, 247, 0.5); + --border-glow: rgba(168, 85, 247, 0.3); +} + +/* 主题: 翠绿矩阵 */ +[data-theme="matrix"] { + --bg-primary: #0a0f0a; + --bg-secondary: #0f1a0f; + --bg-tertiary: #1a2f1a; + --accent-primary: #22c55e; + --accent-secondary: #4ade80; + --accent-glow: rgba(34, 197, 94, 0.5); + --border-glow: rgba(34, 197, 94, 0.3); +} + +/* 主题: 赛博橙 */ +[data-theme="cyber"] { + --bg-primary: #0f0a05; + --bg-secondary: #1a1008; + --bg-tertiary: #2f1f0a; + --accent-primary: #f97316; + --accent-secondary: #fb923c; + --accent-glow: rgba(249, 115, 22, 0.5); + --border-glow: rgba(249, 115, 22, 0.3); +} + +/* 主题: 冰霜白 */ +[data-theme="frost"] { + --bg-primary: #f0f4f8; + --bg-secondary: #e2e8f0; + --bg-tertiary: #cbd5e1; + --bg-hover: #94a3b8; + --text-primary: #1e293b; + --text-secondary: #475569; + --text-muted: #64748b; + --accent-primary: #0ea5e9; + --accent-secondary: #38bdf8; + --border-color: #94a3b8; +} + +/* ==================== 基础样式 ==================== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +/* ==================== 粒子背景 ==================== */ +#particles-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; +} + +/* ==================== 应用容器 ==================== */ +.app-container { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ==================== 头部导航 ==================== */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%); + border-bottom: 1px solid var(--border-color); +} + +.logo { + display: flex; + align-items: center; + gap: 8px; +} + +.logo-icon { + font-size: 24px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(1.1); + } +} + +.logo-text { + font-size: 20px; + font-weight: 700; + background: linear-gradient( + 135deg, + var(--accent-primary), + var(--accent-secondary) + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.logo-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--accent-primary); + color: white; + border-radius: 10px; + font-weight: 600; +} + +.header-nav { + display: flex; + gap: 8px; +} + +.nav-btn { + padding: 8px 16px; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-md); + transition: var(--transition); +} + +.nav-btn:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.nav-btn.active { + color: var(--accent-primary); + border-color: var(--accent-primary); + box-shadow: var(--shadow-glow); +} + +.header-actions { + display: flex; + gap: 8px; +} + +.theme-btn, +.voice-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + cursor: pointer; + font-size: 18px; + transition: var(--transition); +} + +.theme-btn:hover, +.voice-btn:hover { + border-color: var(--accent-primary); + box-shadow: var(--shadow-glow); + transform: scale(1.1); +} + +.voice-btn.recording { + animation: recording 1s infinite; + border-color: var(--error); +} + +@keyframes recording { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); + } + 50% { + box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); + } +} + +/* ==================== 主内容区 ==================== */ +.app-main { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + padding: 24px; + max-width: 1800px; + margin: 0 auto; + width: 100%; +} + +@media (max-width: 1200px) { + .app-main { + grid-template-columns: 1fr; + } +} + +/* ==================== 面板通用样式 ==================== */ +.panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.panel.hidden { + display: none; +} + +/* ==================== URL 栏 ==================== */ +.url-bar { + display: flex; + gap: 12px; + padding: 20px; + background: linear-gradient( + 135deg, + var(--bg-tertiary) 0%, + var(--bg-secondary) 100% + ); + border-bottom: 1px solid var(--border-color); +} + +.method-select { + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--accent-primary); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + border-radius: var(--radius-md); + cursor: pointer; + min-width: 100px; + transition: var(--transition); +} + +.method-select:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: var(--shadow-glow); +} + +.url-input { + flex: 1; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 14px; + border-radius: var(--radius-md); + transition: var(--transition); +} + +.url-input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: var(--shadow-glow); +} + +.url-input::placeholder { + color: var(--text-muted); +} + +/* ==================== 发送按钮 ==================== */ +.send-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: linear-gradient( + 135deg, + var(--accent-primary), + var(--accent-secondary) + ); + border: none; + color: white; + font-size: 14px; + font-weight: 600; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.send-btn::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.2), + transparent + ); + transition: 0.5s; +} + +.send-btn:hover::before { + left: 100%; +} + +.send-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 20px var(--accent-glow); +} + +.send-btn:active { + transform: translateY(0); +} + +.send-btn.loading { + pointer-events: none; + opacity: 0.7; +} + +.send-btn.loading .btn-text { + opacity: 0; +} + +.send-btn.loading::after { + content: ""; + position: absolute; + width: 20px; + height: 20px; + border: 2px solid white; + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ==================== 标签页 ==================== */ +.request-tabs, +.response-tabs { + display: flex; + padding: 0 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.tab-btn { + padding: 12px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: var(--transition); +} + +.tab-btn:hover { + color: var(--text-primary); +} + +.tab-btn.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +/* ==================== 内容区 ==================== */ +.request-content, +.response-content { + padding: 20px; + min-height: 200px; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* ==================== Key-Value 编辑器 ==================== */ +.kv-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kv-row { + display: flex; + gap: 8px; + align-items: center; +} + +.kv-key, +.kv-value { + flex: 1; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + border-radius: var(--radius-sm); + transition: var(--transition); +} + +.kv-key:focus, +.kv-value:focus { + outline: none; + border-color: var(--accent-primary); +} + +.kv-remove { + width: 32px; + height: 32px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 18px; + transition: var(--transition); +} + +.kv-remove:hover { + background: var(--error); + border-color: var(--error); + color: white; +} + +.add-row-btn { + margin-top: 8px; + padding: 8px 16px; + background: transparent; + border: 1px dashed var(--border-color); + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.add-row-btn:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +/* ==================== Header 预设 ==================== */ +.header-presets-container { + margin-top: 20px; + padding: 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.preset-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.preset-group:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.preset-label { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + min-width: 90px; +} + +.preset-btn { + padding: 5px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 11px; + border-radius: 14px; + cursor: pointer; + transition: var(--transition); +} + +.preset-btn:hover { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 8px var(--accent-glow); +} + +.preset-btn.auth-preset { + border-color: var(--warning); + color: var(--warning); +} + +.preset-btn.auth-preset:hover { + background: var(--warning); + border-color: var(--warning); + color: white; +} + +/* ==================== Body 编辑器 ==================== */ +.body-type-selector { + display: flex; + gap: 16px; + margin-bottom: 16px; +} + +.body-type-selector label { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; +} + +.body-type-selector input[type="radio"] { + accent-color: var(--accent-primary); +} + +.body-editor-container { + position: relative; +} + +.body-editor { + width: 100%; + min-height: 200px; + padding: 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + border-radius: var(--radius-md); + resize: vertical; + transition: var(--transition); +} + +.body-editor:focus { + outline: none; + border-color: var(--accent-primary); +} + +.format-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 6px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.format-btn:hover { + background: var(--accent-primary); + color: white; +} + +/* ==================== Auth 配置 ==================== */ +.auth-type-selector { + margin-bottom: 16px; +} + +.auth-type-selector select { + padding: 10px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 14px; + border-radius: var(--radius-md); + cursor: pointer; + min-width: 200px; +} + +.auth-config { + display: flex; + flex-direction: column; + gap: 12px; +} + +.auth-input-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-input-group label { + color: var(--text-secondary); + font-size: 12px; +} + +.auth-input-group input { + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + border-radius: var(--radius-sm); +} + +/* ==================== 响应面板 ==================== */ +.response-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} + +.response-status { + display: flex; + align-items: center; + gap: 12px; +} + +.status-code { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + padding: 4px 12px; + border-radius: var(--radius-sm); + background: var(--bg-primary); +} + +.status-code.success { + color: var(--success); +} +.status-code.redirect { + color: var(--warning); +} +.status-code.error { + color: var(--error); +} + +.status-text { + color: var(--text-secondary); + font-size: 14px; +} + +.response-meta { + display: flex; + gap: 20px; +} + +.meta-item { + font-size: 13px; + color: var(--text-secondary); +} + +.meta-label { + color: var(--text-muted); +} + +.response-body, +.response-headers-view { + background: var(--bg-primary); + padding: 16px; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 400px; + overflow-y: auto; +} + +/* ==================== 历史记录面板 ==================== */ +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} + +.panel-header h2 { + font-size: 16px; + font-weight: 600; +} + +.panel-actions { + display: flex; + gap: 12px; +} + +.search-input { + padding: 8px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 13px; + border-radius: var(--radius-sm); + width: 200px; +} + +.clear-btn { + padding: 8px 16px; + background: var(--error); + border: none; + color: white; + font-size: 13px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.clear-btn:hover { + opacity: 0.8; +} + +.history-list { + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 500px; + overflow-y: auto; +} + +.history-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition); +} + +.history-item:hover { + border-color: var(--accent-primary); +} + +.history-method { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + padding: 4px 8px; + border-radius: var(--radius-sm); + min-width: 60px; + text-align: center; +} + +.history-method.get { + background: #10b98120; + color: var(--success); +} +.history-method.post { + background: #3b82f620; + color: var(--accent-primary); +} +.history-method.put { + background: #f59e0b20; + color: var(--warning); +} +.history-method.delete { + background: #ef444420; + color: var(--error); +} +.history-method.patch { + background: #a855f720; + color: #a855f7; +} + +.history-url { + flex: 1; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-time { + font-size: 12px; + color: var(--text-muted); +} + +.history-status { + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +/* ==================== 滚动条样式 ==================== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-hover); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} diff --git a/tools/static/js/app.js b/tools/static/js/app.js new file mode 100644 index 0000000..88402fb --- /dev/null +++ b/tools/static/js/app.js @@ -0,0 +1,553 @@ +/** + * API Tester - 前端应用主逻辑 + * @author huazm + */ + +// ==================== 粒子背景 ==================== +class ParticleBackground { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.particles = []; + this.particleCount = 80; + this.resize(); + this.init(); + this.animate(); + window.addEventListener('resize', () => this.resize()); + } + + resize() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + } + + init() { + this.particles = []; + for (let i = 0; i < this.particleCount; i++) { + this.particles.push({ + x: Math.random() * this.canvas.width, + y: Math.random() * this.canvas.height, + vx: (Math.random() - 0.5) * 0.5, + vy: (Math.random() - 0.5) * 0.5, + size: Math.random() * 2 + 1, + opacity: Math.random() * 0.5 + 0.2 + }); + } + } + + animate() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // 获取当前主题色 + const style = getComputedStyle(document.documentElement); + const accentColor = style.getPropertyValue('--accent-primary').trim() || '#3b82f6'; + + this.particles.forEach((p, i) => { + // 更新位置 + p.x += p.vx; + p.y += p.vy; + + // 边界检测 + if (p.x < 0 || p.x > this.canvas.width) p.vx *= -1; + if (p.y < 0 || p.y > this.canvas.height) p.vy *= -1; + + // 绘制粒子 + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + this.ctx.fillStyle = accentColor.replace(')', `, ${p.opacity})`).replace('rgb', 'rgba'); + this.ctx.fill(); + + // 连接附近粒子 + for (let j = i + 1; j < this.particles.length; j++) { + const p2 = this.particles[j]; + const dx = p.x - p2.x; + const dy = p.y - p2.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 150) { + this.ctx.beginPath(); + this.ctx.moveTo(p.x, p.y); + this.ctx.lineTo(p2.x, p2.y); + this.ctx.strokeStyle = accentColor.replace(')', `, ${0.1 * (1 - dist / 150)})`).replace('rgb', 'rgba'); + this.ctx.stroke(); + } + } + }); + + requestAnimationFrame(() => this.animate()); + } +} + +// ==================== 主题管理 ==================== +const themes = ['default', 'nebula', 'matrix', 'cyber', 'frost']; +let currentThemeIndex = 0; + +function toggleTheme() { + currentThemeIndex = (currentThemeIndex + 1) % themes.length; + const theme = themes[currentThemeIndex]; + + if (theme === 'default') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } + + localStorage.setItem('api-tester-theme', theme); +} + +function loadTheme() { + const saved = localStorage.getItem('api-tester-theme'); + if (saved && themes.includes(saved)) { + currentThemeIndex = themes.indexOf(saved); + if (saved !== 'default') { + document.documentElement.setAttribute('data-theme', saved); + } + } +} + +// ==================== API 请求 ==================== +async function sendRequest() { + const sendBtn = document.getElementById('send-btn'); + const method = document.getElementById('method-select').value; + const url = document.getElementById('url-input').value.trim(); + + if (!url) { + showToast('请输入 URL', 'error'); + return; + } + + // 收集请求数据 + const headers = collectKeyValues('headers-editor'); + const params = collectKeyValues('params-editor'); + const bodyType = document.querySelector('input[name="bodyType"]:checked')?.value || 'none'; + const body = bodyType !== 'none' ? document.getElementById('body-editor').value : null; + const authType = document.getElementById('auth-type').value; + const authConfig = collectAuthConfig(authType); + + // 显示加载状态 + sendBtn.classList.add('loading'); + + try { + const response = await fetch('/api/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method, url, headers, params, body, + bodyType: bodyType !== 'none' ? bodyType : null, + authType: authType !== 'none' ? authType : null, + authConfig + }) + }); + + const result = await response.json(); + displayResponse(result); + + } catch (error) { + displayResponse({ success: false, error: error.message }); + } finally { + sendBtn.classList.remove('loading'); + } +} + +// ==================== 辅助函数 ==================== +function collectKeyValues(editorId) { + const editor = document.getElementById(editorId); + const rows = editor.querySelectorAll('.kv-row'); + const result = {}; + + rows.forEach(row => { + const key = row.querySelector('.kv-key')?.value.trim(); + const value = row.querySelector('.kv-value')?.value.trim(); + if (key) result[key] = value || ''; + }); + + return Object.keys(result).length > 0 ? result : null; +} + +function collectAuthConfig(authType) { + if (authType === 'none') return null; + + const config = {}; + const container = document.getElementById('auth-config'); + const inputs = container.querySelectorAll('input'); + + inputs.forEach(input => { + if (input.dataset.key) { + config[input.dataset.key] = input.value; + } + }); + + return config; +} + +function displayResponse(result) { + const statusCode = document.getElementById('status-code'); + const statusText = document.getElementById('status-text'); + const responseTime = document.getElementById('response-time'); + const responseSize = document.getElementById('response-size'); + const responseBody = document.getElementById('response-body'); + const responseHeaders = document.getElementById('response-headers-view'); + + if (result.success) { + const code = result.statusCode; + statusCode.textContent = code; + statusCode.className = 'status-code ' + (code < 300 ? 'success' : code < 400 ? 'redirect' : 'error'); + statusText.textContent = getStatusText(code); + responseTime.textContent = `${result.duration}ms`; + responseSize.textContent = formatBytes(result.size || 0); + + // 格式化响应体 + let body = result.body; + try { + const parsed = JSON.parse(body); + body = JSON.stringify(parsed, null, 2); + } catch {} + responseBody.querySelector('code').textContent = body; + + // 响应头 + if (result.headers) { + responseHeaders.querySelector('code').textContent = + JSON.stringify(result.headers, null, 2); + } + } else { + statusCode.textContent = 'ERR'; + statusCode.className = 'status-code error'; + statusText.textContent = result.error || '请求失败'; + responseTime.textContent = result.duration ? `${result.duration}ms` : '--'; + responseSize.textContent = '--'; + responseBody.querySelector('code').textContent = result.error || '请求失败'; + } +} + +function getStatusText(code) { + const statusTexts = { + 200: 'OK', 201: 'Created', 204: 'No Content', + 301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified', + 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', + 404: 'Not Found', 405: 'Method Not Allowed', 500: 'Internal Server Error', + 502: 'Bad Gateway', 503: 'Service Unavailable' + }; + return statusTexts[code] || 'Unknown'; +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 24px; + background: var(--bg-tertiary); + border: 1px solid var(--${type === 'error' ? 'error' : 'accent-primary'}); + color: var(--text-primary); + border-radius: 8px; + z-index: 9999; + animation: slideIn 0.3s ease; + `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3000); +} + +// ==================== 认证配置 UI ==================== +function updateAuthUI(authType) { + const container = document.getElementById('auth-config'); + container.innerHTML = ''; + + const configs = { + bearer: [{ key: 'token', label: 'Token', type: 'password' }], + basic: [ + { key: 'username', label: '用户名', type: 'text' }, + { key: 'password', label: '密码', type: 'password' } + ], + apikey: [ + { key: 'key', label: 'Key Name', type: 'text' }, + { key: 'value', label: 'Key Value', type: 'password' }, + { key: 'location', label: '位置 (header/query)', type: 'text', default: 'header' } + ] + }; + + const fields = configs[authType] || []; + + fields.forEach(field => { + const group = document.createElement('div'); + group.className = 'auth-input-group'; + group.innerHTML = ` + + + `; + container.appendChild(group); + }); +} + +// ==================== Key-Value 编辑器 ==================== +function addKVRow(editorId) { + const editor = document.getElementById(editorId); + const row = document.createElement('div'); + row.className = 'kv-row'; + row.innerHTML = ` + + + + `; + editor.appendChild(row); + + row.querySelector('.kv-remove').addEventListener('click', () => row.remove()); +} + +// ==================== 标签页切换 ==================== +function setupTabs() { + // 请求配置标签页 + document.querySelectorAll('.request-tabs .tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.request-tabs .tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.request-content .tab-content').forEach(c => c.classList.remove('active')); + + btn.classList.add('active'); + document.getElementById(`${btn.dataset.tab}-content`).classList.add('active'); + }); + }); + + // 响应标签页 + document.querySelectorAll('.response-tabs .tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.response-tabs .tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const isBody = btn.dataset.tab === 'response-body'; + document.getElementById('response-body').classList.toggle('hidden', !isBody); + document.getElementById('response-headers-view').classList.toggle('hidden', isBody); + }); + }); + + // 主导航标签页 + document.querySelectorAll('.header-nav .nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // 切换面板显示 + const tab = btn.dataset.tab; + document.getElementById('request-panel').classList.toggle('hidden', tab !== 'request'); + document.getElementById('response-panel').classList.toggle('hidden', tab !== 'request'); + document.getElementById('history-panel').classList.toggle('hidden', tab !== 'history'); + + if (tab === 'history') loadHistory(); + }); + }); +} + + +// ==================== 历史记录 ==================== +async function loadHistory() { + const list = document.getElementById('history-list'); + list.innerHTML = '
加载中...
'; + + try { + const response = await fetch('/api/history?size=50'); + const result = await response.json(); + + if (result.success && result.data.length > 0) { + list.innerHTML = result.data.map(item => ` +
+ ${item.method} + ${item.url} + ${item.statusCode || '--'} + ${new Date(item.createdAt).toLocaleString()} +
+ `).join(''); + } else { + list.innerHTML = '
暂无历史记录
'; + } + } catch (error) { + list.innerHTML = '
加载失败
'; + } +} + +async function loadHistoryItem(id) { + try { + const response = await fetch(`/api/history?size=1000`); + const result = await response.json(); + const item = result.data.find(h => h.id === id); + + if (item) { + document.getElementById('method-select').value = item.method; + document.getElementById('url-input').value = item.url; + + // 切换回请求面板 + document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active')); + document.querySelector('.header-nav .nav-btn[data-tab="request"]').classList.add('active'); + document.getElementById('request-panel').classList.remove('hidden'); + document.getElementById('response-panel').classList.remove('hidden'); + document.getElementById('history-panel').classList.add('hidden'); + + showToast('已加载历史记录', 'info'); + } + } catch (error) { + showToast('加载失败', 'error'); + } +} + +// ==================== 语音输入 ==================== +function setupVoiceInput() { + const voiceBtn = document.getElementById('voice-input'); + + if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) { + voiceBtn.style.display = 'none'; + return; + } + + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.lang = 'zh-CN'; + recognition.continuous = false; + + let isRecording = false; + + voiceBtn.addEventListener('click', () => { + if (isRecording) { + recognition.stop(); + voiceBtn.classList.remove('recording'); + isRecording = false; + } else { + recognition.start(); + voiceBtn.classList.add('recording'); + isRecording = true; + } + }); + + recognition.onresult = (event) => { + const text = event.results[0][0].transcript; + const urlInput = document.getElementById('url-input'); + urlInput.value = text; + voiceBtn.classList.remove('recording'); + isRecording = false; + showToast('语音识别成功', 'info'); + }; + + recognition.onerror = () => { + voiceBtn.classList.remove('recording'); + isRecording = false; + showToast('语音识别失败', 'error'); + }; +} + +// ==================== JSON 格式化 ==================== +function setupFormatButton() { + document.getElementById('format-json-btn').addEventListener('click', () => { + const editor = document.getElementById('body-editor'); + try { + const parsed = JSON.parse(editor.value); + editor.value = JSON.stringify(parsed, null, 2); + showToast('格式化成功', 'info'); + } catch { + showToast('JSON 格式错误', 'error'); + } + }); +} + +// ==================== 预设按钮 ==================== +function setupPresets() { + document.querySelectorAll('.preset-btn').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.key; + const value = btn.dataset.value; + + // 添加到 headers 编辑器 + const editor = document.getElementById('headers-editor'); + const rows = editor.querySelectorAll('.kv-row'); + + // 检查是否已存在该 key + let existingRow = null; + for (const row of rows) { + const keyInput = row.querySelector('.kv-key'); + if (keyInput.value === key) { + existingRow = row; + break; + } + } + + if (existingRow) { + // 更新现有行 + const valueInput = existingRow.querySelector('.kv-value'); + valueInput.value = value; + valueInput.focus(); + // 如果是鉴权类型,光标定位到值末尾方便输入 token + if (btn.classList.contains('auth-preset')) { + valueInput.setSelectionRange(value.length, value.length); + } + } else { + // 添加新行 + addKVRow('headers-editor'); + const newRow = editor.lastElementChild; + newRow.querySelector('.kv-key').value = key; + const valueInput = newRow.querySelector('.kv-value'); + valueInput.value = value; + + // 如果是鉴权或空值类型,自动聚焦到值输入框 + if (btn.classList.contains('auth-preset') || value === '') { + valueInput.focus(); + valueInput.setSelectionRange(value.length, value.length); + } + } + + showToast(`已添加 ${key}`, 'info'); + }); + }); +} + +// ==================== 初始化 ==================== +document.addEventListener('DOMContentLoaded', () => { + // 初始化粒子背景 + new ParticleBackground(document.getElementById('particles-canvas')); + + // 加载主题 + loadTheme(); + + // 设置事件监听 + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + document.getElementById('send-btn').addEventListener('click', sendRequest); + document.getElementById('auth-type').addEventListener('change', (e) => updateAuthUI(e.target.value)); + + // 添加行按钮 + document.querySelectorAll('.add-row-btn').forEach(btn => { + btn.addEventListener('click', () => addKVRow(btn.dataset.target)); + }); + + // 删除行按钮 + document.querySelectorAll('.kv-remove').forEach(btn => { + btn.addEventListener('click', () => btn.parentElement.remove()); + }); + + // 清空历史 + document.getElementById('clear-history')?.addEventListener('click', async () => { + if (confirm('确定要清空所有历史记录吗?')) { + await fetch('/api/history/clear', { method: 'DELETE' }); + loadHistory(); + } + }); + + // 设置标签页 + setupTabs(); + setupVoiceInput(); + setupFormatButton(); + setupPresets(); + + // 键盘快捷键 + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + sendRequest(); + } + }); +}); + diff --git a/tools/templates/index.html b/tools/templates/index.html new file mode 100644 index 0000000..6d7ba32 --- /dev/null +++ b/tools/templates/index.html @@ -0,0 +1,208 @@ + + + + + + API Tester - 未来科技接口测试工具 + + + + + + + + + + +
+ +
+ + +
+ + +
+
+ + +
+ +
+ +
+ + + +
+ + +
+ + + + +
+ + +
+ +
+
+
+ + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + + +
+
+ Content-Type: + + + + + +
+
+ Accept: + + + +
+
+ 鉴权: + + + + +
+
+ 其他常用: + + + + + + +
+
+
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ -- + 等待请求 +
+
+ 耗时: -- + 大小: -- +
+
+
+ + +
+
+
// 响应将显示在这里
+ +
+
+
+ + + +
+ + + + + diff --git a/web_client/.env.example b/web_client/.env.example new file mode 100644 index 0000000..4313969 --- /dev/null +++ b/web_client/.env.example @@ -0,0 +1,24 @@ +# AI助手Web客户端环境配置示例 +# 复制此文件为 .env 并根据实际情况修改 + +# 服务端口 +PORT=5000 + +# 调试模式 (True/False) +DEBUG=True + +# Flask密钥(生产环境请使用随机字符串) +SECRET_KEY=ai-assistant-web-client-secret-key + +# API基础URL(如果需要代理到其他服务器) +# API_BASE_URL=http://localhost:8082 + +# 日志级别 (DEBUG/INFO/WARNING/ERROR) +LOG_LEVEL=INFO + +# 默认应用ID +DEFAULT_APP_ID=15 + +# CORS允许的源(多个用逗号分隔) +# CORS_ORIGINS=http://localhost:3000,http://localhost:8080 + diff --git a/web_client/DEPLOYMENT.md b/web_client/DEPLOYMENT.md new file mode 100644 index 0000000..fea09ee --- /dev/null +++ b/web_client/DEPLOYMENT.md @@ -0,0 +1,331 @@ +# 🚀 部署指南 + +## 生产环境部署 + +### 使用 Gunicorn(推荐) + +#### 1. 安装 Gunicorn + +```bash +pip3 install gunicorn +``` + +#### 2. 创建 Gunicorn 配置文件 + +创建 `gunicorn.conf.py`: + +```python +# Gunicorn配置 +bind = "0.0.0.0:5000" +workers = 4 +worker_class = "sync" +timeout = 120 +keepalive = 5 + +# 日志 +accesslog = "logs/access.log" +errorlog = "logs/error.log" +loglevel = "info" + +# 进程命名 +proc_name = "ai-assistant-web-client" + +# 后台运行 +daemon = False +``` + +#### 3. 启动服务 + +```bash +# 创建日志目录 +mkdir -p logs + +# 启动Gunicorn +gunicorn -c gunicorn.conf.py app:app +``` + +### 使用 uWSGI + +#### 1. 安装 uWSGI + +```bash +pip3 install uwsgi +``` + +#### 2. 创建 uWSGI 配置文件 + +创建 `uwsgi.ini`: + +```ini +[uwsgi] +module = app:app +master = true +processes = 4 +socket = 0.0.0.0:5000 +protocol = http +chmod-socket = 660 +vacuum = true +die-on-term = true +``` + +#### 3. 启动服务 + +```bash +uwsgi --ini uwsgi.ini +``` + +## Docker 部署 + +### 1. 创建 Dockerfile + +创建 `Dockerfile`: + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +# 复制依赖文件 +COPY requirements.txt . + +# 安装依赖 +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +# 复制应用文件 +COPY . . + +# 暴露端口 +EXPOSE 5000 + +# 启动命令 +CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "4", "app:app"] +``` + +### 2. 创建 docker-compose.yml + +```yaml +version: '3.8' + +services: + web-client: + build: . + ports: + - "5000:5000" + environment: + - DEBUG=False + - PORT=5000 + volumes: + - ./logs:/app/logs + restart: unless-stopped +``` + +### 3. 构建和运行 + +```bash +# 构建镜像 +docker-compose build + +# 启动服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f + +# 停止服务 +docker-compose down +``` + +## Nginx 反向代理 + +### 配置示例 + +创建 `/etc/nginx/sites-available/ai-assistant-web`: + +```nginx +server { + listen 80; + server_name your-domain.com; + + # 静态文件 + location /static { + alias /path/to/web_client/static; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # 代理到Flask应用 + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket支持(如果需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +启用配置: + +```bash +sudo ln -s /etc/nginx/sites-available/ai-assistant-web /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +## Systemd 服务 + +### 创建服务文件 + +创建 `/etc/systemd/system/ai-assistant-web.service`: + +```ini +[Unit] +Description=AI Assistant Web Client +After=network.target + +[Service] +Type=notify +User=www-data +Group=www-data +WorkingDirectory=/path/to/web_client +Environment="PATH=/path/to/venv/bin" +ExecStart=/path/to/venv/bin/gunicorn -c gunicorn.conf.py app:app +ExecReload=/bin/kill -s HUP $MAINPID +KillMode=mixed +TimeoutStopSec=5 +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +### 管理服务 + +```bash +# 重载systemd配置 +sudo systemctl daemon-reload + +# 启动服务 +sudo systemctl start ai-assistant-web + +# 开机自启 +sudo systemctl enable ai-assistant-web + +# 查看状态 +sudo systemctl status ai-assistant-web + +# 查看日志 +sudo journalctl -u ai-assistant-web -f +``` + +## 性能优化 + +### 1. 启用 Gzip 压缩 + +在 Nginx 配置中添加: + +```nginx +gzip on; +gzip_vary on; +gzip_min_length 1024; +gzip_types text/plain text/css text/xml text/javascript application/javascript application/json; +``` + +### 2. 静态文件缓存 + +```nginx +location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; +} +``` + +### 3. 使用 CDN + +将静态资源(CSS、JS、字体)托管到CDN。 + +## 安全建议 + +### 1. 使用 HTTPS + +```bash +# 使用 Let's Encrypt +sudo certbot --nginx -d your-domain.com +``` + +### 2. 设置安全头 + +在 Nginx 配置中添加: + +```nginx +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "no-referrer-when-downgrade" always; +``` + +### 3. 限制请求速率 + +```nginx +limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + +location /api/ { + limit_req zone=api burst=20 nodelay; +} +``` + +## 监控和日志 + +### 1. 日志轮转 + +创建 `/etc/logrotate.d/ai-assistant-web`: + +``` +/path/to/web_client/logs/*.log { + daily + rotate 14 + compress + delaycompress + notifempty + create 0640 www-data www-data + sharedscripts + postrotate + systemctl reload ai-assistant-web > /dev/null 2>&1 || true + endscript +} +``` + +### 2. 健康检查 + +使用 `/api/health` 端点进行健康检查。 + +## 故障排查 + +### 查看日志 + +```bash +# Gunicorn日志 +tail -f logs/error.log + +# Nginx日志 +tail -f /var/log/nginx/error.log + +# Systemd日志 +journalctl -u ai-assistant-web -f +``` + +### 常见问题 + +1. **502 Bad Gateway** - 检查Flask应用是否运行 +2. **静态文件404** - 检查Nginx静态文件路径配置 +3. **CORS错误** - 检查Flask-CORS配置 + +--- + +**部署成功后,记得测试所有功能!** ✅ + diff --git a/web_client/QUICKSTART.md b/web_client/QUICKSTART.md new file mode 100644 index 0000000..ad1e104 --- /dev/null +++ b/web_client/QUICKSTART.md @@ -0,0 +1,140 @@ +# 🚀 快速启动指南 + +## 一键启动 + +### 方法1:使用启动脚本(推荐) + +```bash +cd test-client/web_client +./start.sh +``` + +### 方法2:手动启动 + +```bash +cd test-client/web_client + +# 安装依赖(首次运行) +pip3 install -r requirements.txt + +# 启动服务 +python3 app.py +``` + +### 方法3:指定端口启动 + +```bash +# 如果5000端口被占用,可以指定其他端口 +PORT=5001 python3 app.py +``` + +## 访问应用 + +启动成功后,在浏览器中打开: + +- **默认地址**: http://localhost:5000 +- **自定义端口**: http://localhost:5001 (如果使用了PORT=5001) + +## 🎨 界面预览 + +启动后你将看到: + +### 左侧面板 +- 👤 **用户画像** - 显示用户信息和个性化推荐 +- ✅ **待办事项** - 任务管理功能 +- 🔔 **提醒设置** - 定时提醒展示 + +### 右侧主区域 +- 🎨 **主题切换** - 4种配色方案(深空黑、极光蓝、霓虹紫、科技绿) +- 💬 **对话区域** - AI助手聊天界面 +- ⚡ **快捷回复** - 常用问题快速输入 +- 🎤 **语音输入** - 语音波形动画效果 + +## 💡 使用技巧 + +### 发送消息 +1. 在底部输入框输入文字 +2. 按 `Enter` 键或点击发送按钮 +3. 等待AI回复(会显示打字动画) + +### 快捷回复 +点击顶部的快捷回复按钮,自动填充常用问题: +- 💡 今日天气如何? +- 📅 明天的会议安排 +- ✅ 帮我制定工作计划 + +### 切换主题 +点击顶部导航栏的主题按钮: +- **深空黑** - 默认深色主题 +- **极光蓝** - 蓝色科技风 +- **霓虹紫** - 紫色梦幻风 +- **科技绿** - 绿色矩阵风 + +### 管理待办 +在左侧面板的待办事项区域: +1. 输入新任务 +2. 按 `Enter` 或点击 `+` 按钮添加 +3. 勾选复选框标记完成 + +## 🔧 常见问题 + +### Q: 端口被占用怎么办? +A: 使用环境变量指定其他端口: +```bash +PORT=5001 python3 app.py +``` + +### Q: 样式显示不正常? +A: 检查以下几点: +1. 确保 `static/css/style.css` 文件存在 +2. 清除浏览器缓存后刷新 +3. 检查浏览器控制台是否有错误 + +### Q: 无法发送消息? +A: 检查: +1. 浏览器控制台的Network标签查看请求 +2. 确认后端API服务是否启动 +3. 检查CORS配置 + +### Q: 如何连接真实的AI助手API? +A: 编辑 `app.py`,确保以下导入正确: +```python +from api_client import AIAssistantClient +from config import APPLICATIONS, AI_ASSISTANT_CONFIG +``` + +## 📊 技术栈 + +- **后端**: Flask 2.3+ +- **前端**: Tailwind CSS + Vanilla JS +- **字体**: Pacifico + Inter +- **图标**: Font Awesome 6.4.0 + +## 🎯 下一步 + +1. ✅ 启动应用 +2. ✅ 体验界面效果 +3. ✅ 测试对话功能 +4. 🔄 集成真实API +5. 🚀 部署到生产环境 + +## 📝 开发模式 + +当前运行在开发模式,支持: +- ✅ 热重载(修改代码自动重启) +- ✅ 详细错误信息 +- ✅ 调试工具 + +**注意**: 生产环境请使用 WSGI 服务器(如 Gunicorn) + +## 🆘 获取帮助 + +如遇问题,请检查: +1. 终端输出的错误信息 +2. 浏览器控制台的错误 +3. Flask日志输出 + +--- + +**享受使用AI助手Web客户端!** 🎉 + diff --git a/web_client/README.md b/web_client/README.md new file mode 100644 index 0000000..4fbbde0 --- /dev/null +++ b/web_client/README.md @@ -0,0 +1,219 @@ +# AI助手Web客户端 + +这是一个100%还原 `docs/ai-assistant.html` 原型设计的Web版本AI助手对话客户端。 + +## ✨ 特性 + +### 视觉效果 +- ✅ 完全还原原型的双栏布局(左侧用户信息面板 + 右侧对话区域) +- ✅ 精确匹配的颜色方案和渐变效果 +- ✅ 动态粒子背景效果 +- ✅ 消息滑入动画 +- ✅ AI打字指示器动画 +- ✅ 按钮悬停发光效果 +- ✅ 语音波形动画 + +### 功能特性 +- ✅ 实时对话交互 +- ✅ 智能回复快捷按钮 +- ✅ 主题切换(深空黑、极光蓝、霓虹紫、科技绿) +- ✅ 字体选择 +- ✅ 待办事项管理 +- ✅ 提醒设置展示 +- ✅ 连接状态指示 + +### 技术栈 +- **后端**: Flask 2.3+ +- **前端**: HTML5 + Tailwind CSS + Vanilla JavaScript +- **字体**: Pacifico (标题) + Inter (正文) +- **图标**: Font Awesome 6.4.0 + +## 📁 项目结构 + +``` +web_client/ +├── app.py # Flask应用主文件 +├── requirements.txt # Python依赖 +├── README.md # 本文档 +├── static/ +│ ├── css/ +│ │ └── style.css # 自定义样式(动画、效果) +│ ├── js/ +│ │ └── main.js # 前端交互逻辑 +│ └── assets/ # 静态资源(图片等) +└── templates/ + └── index.html # 主页面模板 +``` + +## 🚀 快速开始 + +### 1. 安装依赖 + +```bash +cd test-client/web_client +pip install -r requirements.txt +``` + +### 2. 启动服务 + +```bash +python app.py +``` + +默认端口:`5000` + +### 3. 访问应用 + +打开浏览器访问:`http://localhost:5000` + +## 🎨 界面还原度 + +### 布局结构 +- ✅ 左侧固定宽度(320px)用户信息面板 +- ✅ 右侧自适应对话区域 +- ✅ 顶部导航栏(64px高度) +- ✅ 底部输入区域 + +### 颜色方案 +```css +/* 背景渐变 */ +background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); + +/* 主色调 */ +--primary: #6366f1; +--secondary: #8b5cf6; + +/* 用户消息气泡 */ +background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + +/* AI消息气泡 */ +background: linear-gradient(135deg, #06b6d4 0%, #10b981 100%); +``` + +### 动画效果 +1. **粒子背景** - 50个动态浮动粒子 +2. **消息滑入** - 0.3s ease-out 动画 +3. **打字指示器** - 3个点的波浪动画 +4. **按钮发光** - hover时的阴影和位移效果 +5. **脉冲动画** - 2s循环的扩散效果 +6. **语音波形** - 5个条形的波动动画 + +## 🔌 API集成 + +### 已预留的API接口 + +#### 1. 获取应用列表 +```javascript +GET /api/applications +``` + +#### 2. 发送聊天消息 +```javascript +POST /api/chat/send +Content-Type: application/json + +{ + "appId": 15, + "message": "你好", + "chatId": "optional_chat_id", + "stream": false +} +``` + +#### 3. 健康检查 +```javascript +GET /api/health +``` + +### 集成真实API + +编辑 `app.py`,确保父目录的 `api_client.py` 和 `config.py` 可以正确导入: + +```python +from api_client import AIAssistantClient +from config import APPLICATIONS, AI_ASSISTANT_CONFIG +``` + +## 🎯 使用说明 + +### 基础对话 +1. 在底部输入框输入消息 +2. 点击发送按钮或按Enter键 +3. 等待AI回复(显示打字指示器) + +### 快捷回复 +点击顶部的快捷回复按钮,自动填充常用问题 + +### 主题切换 +点击顶部导航栏的主题按钮切换不同配色方案 + +### 待办事项 +在左侧面板添加和管理待办事项 + +## 🔧 配置说明 + +### 环境变量 + +创建 `.env` 文件(可选): + +```bash +# 服务端口 +PORT=5000 + +# 调试模式 +DEBUG=True + +# API基础URL(如果需要代理) +API_BASE_URL=http://localhost:8082 +``` + +### 修改默认应用 + +编辑 `static/js/main.js`: + +```javascript +let currentAppId = 15; // 修改为你的应用ID +``` + +## 📱 响应式设计 + +- ✅ 桌面端(1920x1080及以上)- 完整布局 +- ✅ 平板端(768px-1920px)- 自适应布局 +- ✅ 移动端(<768px)- 优化的单栏布局 + +## 🐛 故障排查 + +### 样式未加载 +- 检查 `static/css/style.css` 文件是否存在 +- 确认Flask静态文件路径配置正确 + +### JavaScript未执行 +- 检查浏览器控制台是否有错误 +- 确认 `static/js/main.js` 文件是否存在 + +### API调用失败 +- 检查后端服务是否启动 +- 查看浏览器Network标签的请求详情 +- 确认CORS配置正确 + +## 📝 开发计划 + +- [ ] 支持流式响应(SSE) +- [ ] 支持文件上传 +- [ ] 支持语音输入 +- [ ] 支持多会话管理 +- [ ] 支持消息历史记录 +- [ ] 支持用户认证 + +## 📄 许可 + +内部测试工具,仅供开发和测试使用。 + +## 🤝 贡献 + +如需修改或扩展功能,请遵循以下原则: +1. 保持与原型设计的一致性 +2. 添加注释说明修改内容 +3. 测试所有浏览器兼容性 +4. 更新本README文档 + diff --git a/web_client/__pycache__/app.cpython-312.pyc b/web_client/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01e7cc45b6c7c1bead8194226d44913d3870445b GIT binary patch literal 5763 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.3.0 + +# CORS支持 +Flask-CORS>=4.0.0 + +# HTTP请求库(用于调用后端API) +requests>=2.31.0 + +# 环境变量管理(可选) +python-dotenv>=1.0.0 + diff --git a/web_client/start.sh b/web_client/start.sh new file mode 100755 index 0000000..ea6bcc3 --- /dev/null +++ b/web_client/start.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# AI助手Web客户端启动脚本 + +echo "=========================================" +echo " AI助手Web客户端启动脚本" +echo "=========================================" +echo "" + +# 检查Python环境 +if ! command -v python3 &> /dev/null; then + echo "❌ 错误: 未找到Python3,请先安装Python 3.7+" + exit 1 +fi + +echo "✅ Python版本: $(python3 --version)" +echo "" + +# 检查是否在正确的目录 +if [ ! -f "app.py" ]; then + echo "❌ 错误: 请在web_client目录下运行此脚本" + exit 1 +fi + +# 检查依赖是否安装 +echo "📦 检查依赖..." +if ! python3 -c "import flask" 2>/dev/null; then + echo "⚠️ Flask未安装,正在安装依赖..." + pip3 install -r requirements.txt + if [ $? -ne 0 ]; then + echo "❌ 依赖安装失败" + exit 1 + fi + echo "✅ 依赖安装成功" +else + echo "✅ 依赖已安装" +fi + +echo "" +echo "=========================================" +echo " 启动Web服务器..." +echo "=========================================" +echo "" +echo "🌐 访问地址: http://localhost:5000" +echo "📝 按 Ctrl+C 停止服务" +echo "" + +# 启动Flask应用 +python3 app.py + diff --git a/web_client/static/css/style.css b/web_client/static/css/style.css new file mode 100644 index 0000000..10c0df1 --- /dev/null +++ b/web_client/static/css/style.css @@ -0,0 +1,220 @@ +/* AI助手Web客户端样式 - 100%还原原型设计 */ + +@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'); +@import url('https://fonts.googleapis.com/css2?family=Pacifico&family=Inter:wght@300;400;500;600;700&display=swap'); + +/* 全局样式 */ +body { + min-height: 1024px; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); + overflow: hidden; + font-family: 'Inter', sans-serif; + margin: 0; + padding: 0; +} + +/* 玻璃效果 */ +.glass-effect { + background: rgba(30, 41, 59, 0.3); + backdrop-filter: blur(10px); + border: 1px solid rgba(100, 100, 100, 0.2); +} + +/* 霓虹边框 */ +.neon-border { + box-shadow: 0 0 10px rgba(99, 102, 241, 0.5), 0 0 20px rgba(99, 102, 241, 0.3); +} + +/* 用户消息气泡 */ +.user-bubble { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + box-shadow: 0 0 15px rgba(99, 102, 241, 0.4); +} + +/* AI消息气泡 */ +.ai-bubble { + background: linear-gradient(135deg, #06b6d4 0%, #10b981 100%); + box-shadow: 0 0 15px rgba(6, 182, 212, 0.4); +} + +/* 脉冲动画 */ +.pulse { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7); } + 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); } + 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } +} + +/* 发光按钮 */ +.glow-button { + transition: all 0.3s ease; +} + +.glow-button:hover { + box-shadow: 0 0 15px rgba(99, 102, 241, 0.8); + transform: translateY(-2px); +} + +/* 粒子背景 */ +.particles { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + pointer-events: none; +} + +.particle { + position: absolute; + border-radius: 50%; + background: rgba(99, 102, 241, 0.3); + animation: float 6s infinite ease-in-out; +} + +@keyframes float { + 0%, 100% { transform: translateY(0) translateX(0); opacity: 0.3; } + 50% { transform: translateY(-20px) translateX(10px); opacity: 0.8; } +} + +/* 打字指示器 */ +.typing-indicator { + display: inline-flex; + align-items: center; +} + +.typing-dot { + width: 8px; + height: 8px; + background: #10b981; + border-radius: 50%; + margin: 0 2px; + animation: typing 1.4s infinite ease-in-out; +} + +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-5px); } +} + +/* 滑入动画 */ +.slide-in { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 淡入动画 */ +.fade-in { + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* 通知徽章 */ +.notification-badge { + position: absolute; + top: -5px; + right: -5px; + background: #ef4444; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + animation: pulse 2s infinite; +} + +/* 语音波形 */ +.voice-wave { + display: flex; + align-items: center; + justify-content: center; +} + +.wave-bar { + width: 2px; + height: 10px; + background: #6366f1; + margin: 0 1px; + animation: wave 1.2s infinite ease-in-out; +} + +.wave-bar:nth-child(2) { animation-delay: 0.2s; } +.wave-bar:nth-child(3) { animation-delay: 0.4s; } +.wave-bar:nth-child(4) { animation-delay: 0.6s; } +.wave-bar:nth-child(5) { animation-delay: 0.8s; } + +@keyframes wave { + 0%, 100% { height: 10px; } + 50% { height: 20px; } +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(30, 41, 59, 0.3); +} + +::-webkit-scrollbar-thumb { + background: rgba(99, 102, 241, 0.5); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(99, 102, 241, 0.7); +} + +/* 主题颜色变量 */ +:root { + --primary: #6366f1; + --secondary: #8b5cf6; + --bg-dark: #0f172a; + --bg-slate: #1e293b; + --text-white: #ffffff; + --text-slate: #94a3b8; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .user-bubble, .ai-bubble { + max-width: 80% !important; + } +} + +/* 隐藏元素 */ +.hidden { + display: none !important; +} + +/* 加载动画 */ +.loading { + opacity: 0.6; + pointer-events: none; +} + diff --git a/web_client/static/js/main.js b/web_client/static/js/main.js new file mode 100644 index 0000000..cbef5c3 --- /dev/null +++ b/web_client/static/js/main.js @@ -0,0 +1,324 @@ +/** + * AI助手Web客户端 - 前端交互逻辑 + * 100%还原原型设计的交互效果 + */ + +// 全局变量 +let currentChatId = null; +let currentAppId = 15; // 默认使用人岗匹配应用 +let isTyping = false; + +// API配置 +const API_BASE_URL = ''; // 使用相对路径 + +/** + * 创建动态粒子背景 + */ +function createParticles() { + const particlesContainer = document.getElementById('particles'); + const particleCount = 50; + + for (let i = 0; i < particleCount; i++) { + const particle = document.createElement('div'); + particle.className = 'particle'; + + // 随机位置和大小 + const size = Math.random() * 4 + 1; + const posX = Math.random() * 100; + const posY = Math.random() * 100; + + particle.style.width = `${size}px`; + particle.style.height = `${size}px`; + particle.style.left = `${posX}%`; + particle.style.top = `${posY}%`; + + // 随机动画延迟 + particle.style.animationDelay = `${Math.random() * 5}s`; + + particlesContainer.appendChild(particle); + } +} + +/** + * 添加消息到聊天容器 + */ +function addMessage(message, isUser = true) { + const chatContainer = document.getElementById('chat-container'); + + // 移除打字指示器 + removeTypingIndicator(); + + const messageDiv = document.createElement('div'); + messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'} slide-in`; + + const bubbleClass = isUser ? 'user-bubble' : 'ai-bubble'; + const roundedClass = isUser ? 'rounded-br-md' : 'rounded-bl-md'; + + messageDiv.innerHTML = ` +
+

${escapeHtml(message)}

+
+ `; + + chatContainer.appendChild(messageDiv); + scrollToBottom(); +} + +/** + * 显示打字指示器 + */ +function showTypingIndicator() { + if (isTyping) return; + + isTyping = true; + const chatContainer = document.getElementById('chat-container'); + + const typingDiv = document.createElement('div'); + typingDiv.id = 'typing-indicator'; + typingDiv.className = 'flex justify-start'; + typingDiv.innerHTML = ` +
+
+
+
+
+
+
+ `; + + chatContainer.appendChild(typingDiv); + scrollToBottom(); +} + +/** + * 移除打字指示器 + */ +function removeTypingIndicator() { + const typingIndicator = document.getElementById('typing-indicator'); + if (typingIndicator) { + typingIndicator.remove(); + isTyping = false; + } +} + +/** + * 滚动到底部 + */ +function scrollToBottom() { + const chatContainer = document.getElementById('chat-container'); + chatContainer.scrollTop = chatContainer.scrollHeight; +} + +/** + * HTML转义 + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * 发送消息 + */ +async function sendMessage() { + const input = document.getElementById('message-input'); + const message = input.value.trim(); + + if (!message) return; + + // 添加用户消息 + addMessage(message, true); + input.value = ''; + + // 显示打字指示器 + showTypingIndicator(); + + try { + // 调用API + const response = await fetch(`${API_BASE_URL}/api/chat/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + appId: currentAppId, + message: message, + chatId: currentChatId, + stream: false + }) + }); + + const data = await response.json(); + + // 移除打字指示器 + removeTypingIndicator(); + + if (data.code === 200 && data.data) { + // 添加AI回复 + addMessage(data.data.answer, false); + + // 更新chatId + if (data.data.chatId) { + currentChatId = data.data.chatId; + } + } else { + addMessage(`错误: ${data.message || '未知错误'}`, false); + } + } catch (error) { + console.error('发送消息失败:', error); + removeTypingIndicator(); + addMessage('抱歉,发送消息失败,请稍后重试。', false); + } +} + +/** + * 快捷回复按钮点击 + */ +function handleQuickReply(text) { + const input = document.getElementById('message-input'); + input.value = text; + input.focus(); +} + +/** + * 主题切换 + */ +function changeTheme(theme) { + const body = document.body; + + // 移除所有主题类 + body.classList.remove('theme-dark', 'theme-blue', 'theme-purple', 'theme-green'); + + // 添加新主题类 + body.classList.add(`theme-${theme}`); + + // 更新背景渐变 + const gradients = { + dark: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', + blue: 'linear-gradient(135deg, #0c4a6e 0%, #075985 50%, #0c4a6e 100%)', + purple: 'linear-gradient(135deg, #581c87 0%, #6b21a8 50%, #581c87 100%)', + green: 'linear-gradient(135deg, #064e3b 0%, #065f46 50%, #064e3b 100%)' + }; + + body.style.background = gradients[theme] || gradients.dark; +} + +/** + * 字体切换 + */ +function changeFont(font) { + const chatContainer = document.getElementById('chat-container'); + chatContainer.style.fontFamily = font; +} + +/** + * 开始新会话 + */ +function startNewChat() { + currentChatId = null; + const chatContainer = document.getElementById('chat-container'); + + // 清空聊天记录(保留欢迎消息) + const messages = chatContainer.querySelectorAll('.slide-in'); + messages.forEach(msg => msg.remove()); + + // 添加欢迎消息 + addMessage('您好!我是AI助手,有什么可以帮助您的吗?', false); +} + +/** + * 待办事项管理 + */ +function addTodoItem() { + const input = document.getElementById('todo-input'); + const text = input.value.trim(); + + if (!text) return; + + const todoList = document.getElementById('todo-list'); + const todoItem = document.createElement('div'); + todoItem.className = 'flex items-center space-x-2 p-2 bg-slate-800/30 rounded-lg'; + todoItem.innerHTML = ` + + ${escapeHtml(text)} + `; + + todoList.appendChild(todoItem); + input.value = ''; +} + +/** + * 页面加载完成后初始化 + */ +document.addEventListener('DOMContentLoaded', function() { + console.log('AI助手Web客户端已加载'); + + // 创建粒子背景 + createParticles(); + + // 绑定发送按钮事件 + const sendButton = document.getElementById('send-button'); + if (sendButton) { + sendButton.addEventListener('click', sendMessage); + } + + // 绑定输入框回车事件 + const messageInput = document.getElementById('message-input'); + if (messageInput) { + messageInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + } + + // 绑定快捷回复按钮 + const quickReplyButtons = document.querySelectorAll('.quick-reply-btn'); + quickReplyButtons.forEach(btn => { + btn.addEventListener('click', function() { + const text = this.textContent.trim(); + // 移除图标文本 + const cleanText = text.replace(/[💡📅✅]/g, '').trim(); + handleQuickReply(cleanText); + }); + }); + + // 绑定主题切换按钮 + const themeButtons = document.querySelectorAll('.theme-btn'); + themeButtons.forEach(btn => { + btn.addEventListener('click', function() { + const theme = this.dataset.theme; + changeTheme(theme); + }); + }); + + // 绑定字体选择 + const fontSelect = document.getElementById('font-select'); + if (fontSelect) { + fontSelect.addEventListener('change', function() { + changeFont(this.value); + }); + } + + // 绑定待办事项添加 + const todoAddButton = document.getElementById('todo-add-btn'); + const todoInput = document.getElementById('todo-input'); + if (todoAddButton && todoInput) { + todoAddButton.addEventListener('click', addTodoItem); + todoInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addTodoItem(); + } + }); + } + + // 添加欢迎消息 + setTimeout(() => { + addMessage('您好!我是AI助手,有什么可以帮助您的吗?', false); + }, 500); +}); + + diff --git a/web_client/templates/index.html b/web_client/templates/index.html new file mode 100644 index 0000000..083b3d1 --- /dev/null +++ b/web_client/templates/index.html @@ -0,0 +1,254 @@ + + + + + + AI 助手对话界面 + + + + + + + + + + + + + + + +
+ +
+ +
+
+

用户画像

+
+
+ U +
+
+

张伟明

+

AI 助手用户

+
+
+ + +
+

个性化推荐

+

根据您的使用习惯,推荐以下功能

+
+ 智能问答 + 语音识别 + 任务管理 +
+
+
+ + +
+

待办事项

+
+
+ + 完成项目报告 +
+
+ + 准备会议材料 +
+
+ + 回复客户邮件 +
+
+
+ + +
+
+ + +
+

提醒设置

+
+
+

每日 9:00 AM

+

晨会准备

+
+
+

每周三 2:00 PM

+

团队同步会议

+
+
+

每月 15 日

+

月度总结报告

+
+
+ +
+
+ + +
+ +
+
+

AI 助手

+
+ + + + +
+
+ + +
+ + + +
+ +
+ +
+ +
+
+
+ + +
+
+ + + +
+
+ + +
+ +
+
+

你好,我想了解一下今天的工作安排。

+
+
+ + +
+
+

您好!根据您的日程安排,今天有以下任务:

+
    +
  • • 9:00 AM - 晨会准备
  • +
  • • 10:30 AM - 客户电话会议
  • +
  • • 2:00 PM - 项目进度汇报
  • +
  • • 4:00 PM - 团队协作讨论
  • +
+
+
+
+ + +
+
+ + + + +
+ +
+ + + +
+ + +
+
+ + + +
+
+ 连接状态 +
+
+
+
+
+
+ + + + + diff --git a/web_client/test_app.py b/web_client/test_app.py new file mode 100644 index 0000000..592b477 --- /dev/null +++ b/web_client/test_app.py @@ -0,0 +1,159 @@ +""" +AI助手Web客户端测试 +测试Flask应用的各个端点 +""" + +import unittest +import json +from app import app + + +class TestWebClient(unittest.TestCase): + """Web客户端测试类""" + + def setUp(self): + """测试前准备""" + self.app = app + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + def test_index_page(self): + """测试主页面""" + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + self.assertIn(b'AI', response.data) + + def test_health_check(self): + """测试健康检查端点""" + response = self.client.get('/api/health') + self.assertEqual(response.status_code, 200) + + data = json.loads(response.data) + self.assertEqual(data['code'], 200) + self.assertEqual(data['message'], 'OK') + self.assertIn('status', data['data']) + self.assertEqual(data['data']['status'], 'healthy') + + def test_get_applications(self): + """测试获取应用列表""" + response = self.client.get('/api/applications') + self.assertEqual(response.status_code, 200) + + data = json.loads(response.data) + self.assertEqual(data['code'], 200) + self.assertIn('data', data) + self.assertIsInstance(data['data'], list) + + def test_send_message_without_content(self): + """测试发送空消息""" + response = self.client.post( + '/api/chat/send', + data=json.dumps({ + 'appId': 15, + 'message': '', + 'chatId': None, + 'stream': False + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 400) + + data = json.loads(response.data) + self.assertEqual(data['code'], 400) + + def test_send_message_with_content(self): + """测试发送正常消息""" + response = self.client.post( + '/api/chat/send', + data=json.dumps({ + 'appId': 15, + 'message': '你好', + 'chatId': None, + 'stream': False + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + + data = json.loads(response.data) + self.assertEqual(data['code'], 200) + self.assertIn('data', data) + self.assertIn('answer', data['data']) + + def test_cors_headers(self): + """测试CORS头""" + response = self.client.get('/api/health') + self.assertIn('Access-Control-Allow-Origin', response.headers) + + def test_static_files(self): + """测试静态文件访问""" + # 测试CSS文件 + response = self.client.get('/static/css/style.css') + self.assertEqual(response.status_code, 200) + + # 测试JS文件 + response = self.client.get('/static/js/main.js') + self.assertEqual(response.status_code, 200) + + +class TestAPIResponses(unittest.TestCase): + """API响应格式测试""" + + def setUp(self): + """测试前准备""" + self.app = app + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + def test_response_format(self): + """测试响应格式统一性""" + response = self.client.get('/api/health') + data = json.loads(response.data) + + # 检查必需字段 + self.assertIn('code', data) + self.assertIn('message', data) + self.assertIn('data', data) + + # 检查字段类型 + self.assertIsInstance(data['code'], int) + self.assertIsInstance(data['message'], str) + + def test_error_response_format(self): + """测试错误响应格式""" + response = self.client.post( + '/api/chat/send', + data=json.dumps({'message': ''}), + content_type='application/json' + ) + data = json.loads(response.data) + + # 错误响应也应该有统一格式 + self.assertIn('code', data) + self.assertIn('message', data) + self.assertEqual(data['code'], 400) + + +def run_tests(): + """运行所有测试""" + # 创建测试套件 + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # 添加测试 + suite.addTests(loader.loadTestsFromTestCase(TestWebClient)) + suite.addTests(loader.loadTestsFromTestCase(TestAPIResponses)) + + # 运行测试 + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # 返回测试结果 + return result.wasSuccessful() + + +if __name__ == '__main__': + import sys + success = run_tests() + sys.exit(0 if success else 1) +