This commit is contained in:
parent
f289955974
commit
d41c1cbc84
|
|
@ -2,7 +2,7 @@
|
|||
"""
|
||||
会话状态
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import date as Date, datetime
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
|
||||
|
|
@ -388,10 +388,7 @@ class ConversationState(rx.State):
|
|||
|
||||
# 运行并流式输出事件
|
||||
stream_events = run_stream_events(
|
||||
deps=Deps(
|
||||
account_id=conversation.workflow.deps["account_id"],
|
||||
connection_pool=connection_pool,
|
||||
),
|
||||
user_id=self.user_id,
|
||||
user_prompt=user_prompt,
|
||||
message_history=message_history,
|
||||
usage=usage,
|
||||
|
|
@ -696,10 +693,17 @@ class ConversationState(rx.State):
|
|||
match type:
|
||||
# 预定航班
|
||||
case WorkflowType.BOOK_FLIGHT:
|
||||
from application.workshop.book_flight import deps
|
||||
from application.workshop.book_flight import Deps
|
||||
|
||||
# 初始化预定航班工作流
|
||||
workflow = Workflow(type=type, deps=deps)
|
||||
workflow = Workflow(
|
||||
type=type,
|
||||
deps=Deps(
|
||||
date=Date(2025, 1, 10),
|
||||
origin_airport_code="SFO",
|
||||
destination_airport_code="ANC",
|
||||
),
|
||||
)
|
||||
# 设置用户提示词
|
||||
self.set_user_prompt(
|
||||
f"帮我查询并预定在 {workflow.deps.date.strftime('%Y-%m-%d')} 从 {workflow.deps.origin_airport_code} 到 {workflow.deps.destination_airport_code} 的航班"
|
||||
|
|
@ -707,8 +711,10 @@ class ConversationState(rx.State):
|
|||
|
||||
# 银行客户服务
|
||||
case WorkflowType.BANK_CUSTOMER_SERVICE:
|
||||
from application.workshop.bank_customer_service import Deps
|
||||
|
||||
# 初始化银行客户服务工作流
|
||||
workflow = Workflow(type=type, deps={"account_id": self.user_id})
|
||||
workflow = Workflow(type=type)
|
||||
# 设置用户提示词
|
||||
self.set_user_prompt(f"帮我查询账户余额")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
"""
|
||||
银行客户服务
|
||||
"""
|
||||
from typing import Any, AsyncGenerator
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from asyncpg import Pool
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai import (
|
||||
Agent,
|
||||
|
|
@ -18,6 +17,7 @@ from pydantic_ai import (
|
|||
from pydantic_ai.messages import AgentStreamEvent
|
||||
from pydantic_ai.run import AgentRunResultEvent
|
||||
|
||||
from application.workshop.database import connection_pool
|
||||
from application.workshop.models import (
|
||||
DEEPSEEK_V4_FLASH_CHAT_MODEL,
|
||||
DEEPSEEK_V4_FLASH_CHAT_MODEL_SETTINGS,
|
||||
|
|
@ -29,48 +29,51 @@ class Deps(BaseModel):
|
|||
依赖类
|
||||
"""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True} # 允许任意类型
|
||||
|
||||
account_id: str = Field(..., description="账户唯一标识")
|
||||
connection_pool: Pool = Field(..., description="连接池")
|
||||
user_id: str = Field(..., description="用户唯一标识")
|
||||
|
||||
|
||||
# 账户上下文能力
|
||||
account_context = Capability[Deps](
|
||||
id="account-context",
|
||||
description="提供账户信息,用于客服应答",
|
||||
defer_loading=False, # 延迟加载置为否,立即加载
|
||||
# 用户上下文能力
|
||||
user_context = Capability[Deps](
|
||||
id="user-context",
|
||||
description="提供用户信息,用于客服应答",
|
||||
defer_loading=False, # 延迟加载置为否(立即加载)
|
||||
)
|
||||
|
||||
|
||||
@account_context.instructions
|
||||
async def query_account_name(ctx: RunContext[Deps]) -> str:
|
||||
@user_context.instructions
|
||||
async def query_user_name(ctx: RunContext[Deps]) -> str:
|
||||
"""
|
||||
查询账户户名
|
||||
查询用户姓名
|
||||
"""
|
||||
async with ctx.deps.connection_pool.acquire() as conn:
|
||||
account_name = await conn.fetchval(
|
||||
"SELECT account_name FROM bank_customer_service WHERE account_id = $1",
|
||||
ctx.deps.account_id,
|
||||
if connection_pool is None:
|
||||
return "数据库连接池未初始化"
|
||||
|
||||
async with connection_pool.acquire() as conn:
|
||||
user_name = await conn.fetchval(
|
||||
"SELECT user_name FROM bank_customer_service WHERE user_id = $1",
|
||||
ctx.deps.user_id,
|
||||
)
|
||||
if account_name is None:
|
||||
return "该账户不存在"
|
||||
return f"该账户户名是 {account_name!r}"
|
||||
if user_name is None:
|
||||
return "该用户不存在"
|
||||
return f"用户姓名是 {user_name!r}"
|
||||
|
||||
|
||||
@account_context.tool(name="查询账户余额")
|
||||
async def query_account_balance(ctx: RunContext[Deps]) -> str:
|
||||
@user_context.tool(name="查询账户余额")
|
||||
async def query_balance(ctx: RunContext[Deps]) -> str:
|
||||
"""
|
||||
查询账户余额
|
||||
"""
|
||||
async with ctx.deps.connection_pool.acquire() as conn:
|
||||
if connection_pool is None:
|
||||
return "数据库连接池未初始化"
|
||||
|
||||
async with connection_pool.acquire() as conn:
|
||||
account_balance = await conn.fetchval(
|
||||
"SELECT account_balance FROM bank_customer_service WHERE account_id = $1",
|
||||
ctx.deps.account_id,
|
||||
"SELECT account_balance FROM bank_customer_service WHERE user_id = $1",
|
||||
ctx.deps.user_id,
|
||||
)
|
||||
if account_balance is None:
|
||||
return "该账户不存在"
|
||||
return f"该账户余额: {account_balance:.2f}"
|
||||
return "该用户不存在"
|
||||
return f"该用户的账户余额: {account_balance:.2f}"
|
||||
|
||||
|
||||
# 智能体
|
||||
|
|
@ -80,7 +83,7 @@ agent = Agent[Deps, str](
|
|||
deps_type=Deps,
|
||||
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
||||
instructions="你是我方银行的客服智能体,为客户提供客服应答。", # 优先使用 instructions
|
||||
capabilities=[account_context],
|
||||
capabilities=[user_context],
|
||||
)
|
||||
|
||||
"""
|
||||
|
|
@ -131,7 +134,7 @@ PartEndEvent(index=0, part=TextPart(content='您好,刘弼仁先生。您账
|
|||
|
||||
|
||||
async def run_stream_events(
|
||||
deps: Any,
|
||||
user_id: str,
|
||||
user_prompt: str | None,
|
||||
message_history: list[ModelMessage],
|
||||
usage: RunUsage,
|
||||
|
|
@ -141,17 +144,13 @@ async def run_stream_events(
|
|||
"""
|
||||
运行并流式输出事件
|
||||
"""
|
||||
if not isinstance(deps, Deps):
|
||||
deps = Deps.model_validate(deps)
|
||||
|
||||
async with agent.run_stream_events(
|
||||
user_prompt=user_prompt,
|
||||
deps=deps,
|
||||
deps=Deps(user_id=user_id),
|
||||
message_history=message_history,
|
||||
usage=usage,
|
||||
usage_limits=usage_limits,
|
||||
deferred_tool_results=deferred_tool_results,
|
||||
) as events:
|
||||
async for event in events:
|
||||
print(event)
|
||||
yield event
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"""
|
||||
预定航班工作流(范式)
|
||||
"""
|
||||
import datetime
|
||||
from datetime import date as Date
|
||||
from typing import Any, AsyncGenerator, cast
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
|
@ -39,7 +39,7 @@ class Flight(BaseModel):
|
|||
"""
|
||||
|
||||
number: str = Field(..., description="航班号")
|
||||
date: datetime.date = Field(..., description="日期")
|
||||
date: Date = Field(..., description="日期")
|
||||
origin_airport_code: str = Field(..., description="出发机场代码")
|
||||
destination_airport_code: str = Field(..., description="到达机场代码")
|
||||
airfare: int = Field(..., description="机票价格")
|
||||
|
|
@ -62,7 +62,7 @@ class Deps(BaseModel):
|
|||
依赖类
|
||||
"""
|
||||
|
||||
date: datetime.date = Field(..., description="日期")
|
||||
date: Date = Field(..., description="日期")
|
||||
origin_airport_code: str = Field(..., description="出发机场代码")
|
||||
destination_airport_code: str = Field(..., description="到达机场代码")
|
||||
searched_flights: list[Flight] | None = Field(
|
||||
|
|
@ -277,13 +277,6 @@ async def validate_output(
|
|||
return output
|
||||
|
||||
|
||||
deps = Deps(
|
||||
date=datetime.date(2025, 1, 10),
|
||||
origin_airport_code="SFO",
|
||||
destination_airport_code="ANC",
|
||||
)
|
||||
|
||||
|
||||
async def run_stream_events(
|
||||
deps: Any,
|
||||
user_prompt: str | None,
|
||||
|
|
|
|||
|
|
@ -3,15 +3,27 @@
|
|||
研究
|
||||
"""
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from asyncpg import Pool
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai import Agent, ModelMessage, RunContext, RunUsage
|
||||
from pydantic import BaseModel, Field, cast
|
||||
from pydantic_ai import Agent, ModelMessage, RunContext
|
||||
from pydantic_ai.usage import RunUsage
|
||||
from pydantic_ai.messages import AgentStreamEvent
|
||||
from pydantic_ai.run import AgentRunResultEvent
|
||||
from pydantic_ai.toolsets import FunctionToolset
|
||||
from sqlmodel import REAL
|
||||
|
||||
from application.workshop.models import DEEPSEEK_V4_FLASH_RESPONSE_MODEL
|
||||
from application.workshop.database import connection_pool
|
||||
|
||||
|
||||
class Deps(BaseModel):
|
||||
"""
|
||||
依赖类
|
||||
"""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True} # 允许任意类型
|
||||
connection_pool: Pool = Field(..., description="连接池")
|
||||
|
||||
|
||||
class Args(BaseModel):
|
||||
|
|
@ -22,11 +34,11 @@ class Args(BaseModel):
|
|||
bocha_web_search = FunctionToolset()
|
||||
|
||||
|
||||
@bocha_web_search.tool_plain() # 普通工具:不使用运行上下文
|
||||
async def web_search(args: Args) -> str:
|
||||
@bocha_web_search.tool(name="联网搜索") # type: ignore
|
||||
async def web_search(ctx: RunContext[Deps], args: Args) -> str:
|
||||
"""
|
||||
联网搜索工具:
|
||||
用于查询互联网实时信息、新闻、政策、行业报告、企业公告、最新数据。
|
||||
用于查询互联网实时信息、新闻、政策法规、行业报告、企业公告、最新数据。
|
||||
适合查询模型静态知识库不存在的外部网页信息。
|
||||
回答时必须标注引用来源URL,禁止编造链接。
|
||||
不要用于简单常识、数学计算、代码编写等无需联网的任务。
|
||||
|
|
@ -89,17 +101,66 @@ async def web_search(args: Args) -> str:
|
|||
|
||||
instruction = """
|
||||
你是研究助手,可调用联网搜索工具。
|
||||
1. 事实、时效、外部资料类问题必须搜索;静态常识可直接回答,支持多次检索、改写query。
|
||||
2. 优先采用一手资料、官方权威信源,慎用自媒体与转载内容。
|
||||
3. 所有事实描述必须标注来源链接,引用格式:`[参考{序号}] {URL}`,序号匹配搜索结果编号。
|
||||
4. 严格区分:【事实】来自网页原文;【推论】基于事实推导;资料不足时标注【推测】,不可当作事实。
|
||||
5. 严禁编造链接和事实;证据不足直接说明,不要强行下结论。
|
||||
6. 组织回答:先陈列证据,再总结结论,结论不能超出资料支撑范围。
|
||||
|
||||
## 一、联网判断
|
||||
需要联网:时效类信息、新闻、声明公告、政策法规、最新数据,以及模型静态知识库无法可靠回答的问题。
|
||||
无需联网:基础常识、数学计算、代码编写与解释、用户已提供完整材料的分析任务、无时间依赖的静态问题。
|
||||
|
||||
【搜索硬约束】
|
||||
1. 最多**2轮联网搜索**,严禁发起第3轮;优先在第一轮构造高信息密度的Query尝试获取完整答案,非必要不执行第二轮搜索。
|
||||
2. 发起新一轮搜索前,必须评估已获取的搜索摘要,现有信息足以回答问题则立即停止调用工具。
|
||||
3. 禁止仅改写同义词、做语义几乎等价的重复搜索;若存在局部信息缺失,优先从已有结果中提取可用内容,不要直接发起新搜索。
|
||||
|
||||
## 二、时间与Query规则
|
||||
时效判断严格以运行时传入的当前日期、年份、时区为准,禁止依靠模型内置记忆猜测时间。
|
||||
- 用户未指定年份:**禁止自行向搜索Query中追加年份**。
|
||||
- 用户明确给出年份:搜索Query必须完整保留用户指定的年份。
|
||||
|
||||
## 三、搜索效率
|
||||
默认只执行1次真实联网搜索;拿到搜索结果后评估证据,满足以下任意条件,必须停止搜索:
|
||||
- 现有信息足以作答;官方一手资料已给出答案;多个可靠来源结论一致;继续搜索仅起到重复确认的作用。
|
||||
|
||||
❌禁止以“再次确认、查找更多来源、补充更多资料”等理由发起额外搜索。
|
||||
|
||||
✅仅在以下4种场景,才允许执行第2轮搜索:
|
||||
①第一轮无有效搜索结果;②回答问题必需的关键事实完全缺失;③权威信源之间事实存在明显冲突;④第一轮搜索结果严重偏题。
|
||||
|
||||
第二轮搜索必须瞄准明确的信息缺口,禁止全盘复刻上一轮查询;**不允许单次回合同时生成多条搜索请求**。
|
||||
严格执行流程:执行搜索 → 阅读解析结果 → 判断信息缺口 → 再决定是否发起二次搜索。
|
||||
|
||||
## 四、信源优先级
|
||||
1级(最高):官网、官方文档、官方公告、一手原始资料
|
||||
2级:权威媒体、专业机构、科研机构
|
||||
3级:其他可信赖网页
|
||||
|
||||
官方资料已经可以完整回答问题时,无需再搜索其他来源做重复验证。
|
||||
自媒体、论坛、转载内容,不可作为重大事实的唯一证据。
|
||||
|
||||
## 五、最新内容校验
|
||||
查询“最新”类问题,不可仅凭网页排序判断时效性;必须核对资料的**发布时间、版本时间,结合运行时提供的当前日期综合判断**。
|
||||
当存在多份结果时,优先选用发布时间更近、信源等级更高的资料。
|
||||
|
||||
## 六、事实规范
|
||||
- 【事实】:搜索返回资料原文直接支持的内容
|
||||
- 【推论】:基于已确认事实做合理推导
|
||||
- 【推测】:证据不足前提下的主观判断
|
||||
|
||||
严禁编造URL、日期、新闻事件、数据、官方声明等任何信息;证据不足时直接如实说明,不得强行得出结论。
|
||||
|
||||
## 七、引用规范
|
||||
引用编号区分搜索轮次:S1‑1、S1‑2 代表第1轮搜索结果;S2‑1、S2‑2 代表第2轮搜索结果。
|
||||
只引用回答中实际用到的资料。
|
||||
引用格式:`[参考 S1‑1] https://xxx`,链接必须来源于 **web_search** 工具返回结果,禁止编造链接。
|
||||
|
||||
## 八、输出规则
|
||||
简单事实类问题:优先给出结论,附带关键证据与来源,回答简洁,避免冗余。
|
||||
复杂研究类问题,按「关键证据 → 分析 → 结论 → 参考来源」结构组织内容;先陈列证据再输出总结,结论不能超出现有资料可支撑的范围。
|
||||
"""
|
||||
|
||||
# 智能体
|
||||
agent = Agent(
|
||||
agent = Agent[Deps, str](
|
||||
model=DEEPSEEK_V4_FLASH_RESPONSE_MODEL,
|
||||
deps_type=Deps,
|
||||
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
||||
instructions=instruction,
|
||||
toolsets=[bocha_web_search],
|
||||
|
|
@ -118,7 +179,11 @@ async def run_stream_events(
|
|||
:param usage: 使用量
|
||||
:return: AsyncGenerator
|
||||
"""
|
||||
if connection_pool is None:
|
||||
return
|
||||
|
||||
async with agent.run_stream_events(
|
||||
deps=Deps(connection_pool=connection_pool),
|
||||
user_prompt=user_prompt,
|
||||
message_history=message_history,
|
||||
usage=usage,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
|
|
@ -0,0 +1,235 @@
|
|||
/* ==================== 玄潮科技 - Custom Styles ==================== */
|
||||
|
||||
/* ----- Base ----- */
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ----- Hero Background Pattern ----- */
|
||||
.hero-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(154, 25, 30, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(154, 25, 30, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.hero-pattern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle, rgba(154, 25, 30, 0.08) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
/* ----- Floating Animations ----- */
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, -30px) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float-slow 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 10s ease-in-out infinite;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
/* ----- Scroll Indicator Bounce ----- */
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||
50% { transform: translateY(10px); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.animate-bounce-slow {
|
||||
animation: bounce-slow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ----- Reveal Animations (Scroll-triggered) ----- */
|
||||
.reveal-up,
|
||||
.reveal-left,
|
||||
.reveal-right {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease;
|
||||
}
|
||||
|
||||
.reveal-left {
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
|
||||
.reveal-right {
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
.reveal-up.revealed,
|
||||
.reveal-left.revealed,
|
||||
.reveal-right.revealed {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
/* Apply reveal animation to elements that have inline animation-delay */
|
||||
.reveal-up {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.reveal-up[style*="animation-delay"] {
|
||||
transition-delay: var(--reveal-delay, 0s);
|
||||
}
|
||||
|
||||
/* ----- Nav Link Active State ----- */
|
||||
.nav-link.active {
|
||||
color: #9a191e;
|
||||
background-color: #fdf0ee;
|
||||
}
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: #9a191e;
|
||||
margin: 2px auto 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ----- Header Scroll Effect ----- */
|
||||
#header.scrolled {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ----- Service Card Hover ----- */
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ----- Advantage Card Hover ----- */
|
||||
.advantage-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ----- Industry Card Hover ----- */
|
||||
.industry-card:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* ----- Stat Card Hover ----- */
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ----- Form Input Styles ----- */
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(154, 25, 30, 0.15);
|
||||
border-color: #9a191e;
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* ----- Back to Top Button ----- */
|
||||
#back-to-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ----- Custom Scrollbar ----- */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #94a3b8;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* ----- Selection ----- */
|
||||
::selection {
|
||||
background: rgba(154, 25, 30, 0.2);
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
/* ----- Mobile Menu Transition ----- */
|
||||
#mobile-menu {
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mobile-menu.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- Gradient Text ----- */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #e53935, #9a191e);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ----- Responsive Adjustments ----- */
|
||||
@media (max-width: 768px) {
|
||||
.service-card,
|
||||
.advantage-card,
|
||||
.stat-card,
|
||||
.industry-card {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.reveal-left,
|
||||
.reveal-right {
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.reveal-left.revealed,
|
||||
.reveal-right.revealed {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- Print Styles ----- */
|
||||
@media print {
|
||||
header,
|
||||
#back-to-top,
|
||||
.animate-float-slow,
|
||||
.animate-float-delayed {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,307 @@
|
|||
/* ==================== 玄潮科技 - Main JavaScript ==================== */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ----- DOM Ready ----- */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initMobileMenu();
|
||||
initSmoothScroll();
|
||||
initHeaderScroll();
|
||||
initActiveNav();
|
||||
initRevealAnimations();
|
||||
initCounters();
|
||||
initBackToTop();
|
||||
initContactForm();
|
||||
initFooterYear();
|
||||
});
|
||||
|
||||
/* ==================== Mobile Menu ==================== */
|
||||
function initMobileMenu() {
|
||||
var btn = document.getElementById('mobile-menu-btn');
|
||||
var menu = document.getElementById('mobile-menu');
|
||||
if (!btn || !menu) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
menu.classList.toggle('hidden');
|
||||
var icon = btn.querySelector('i');
|
||||
if (menu.classList.contains('hidden')) {
|
||||
icon.className = 'fas fa-bars text-xl';
|
||||
} else {
|
||||
icon.className = 'fas fa-xmark text-xl';
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when a link is clicked
|
||||
var links = menu.querySelectorAll('.mobile-nav-link');
|
||||
links.forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
menu.classList.add('hidden');
|
||||
btn.querySelector('i').className = 'fas fa-bars text-xl';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Smooth Scroll ==================== */
|
||||
function initSmoothScroll() {
|
||||
var links = document.querySelectorAll('a[href^="#"]');
|
||||
links.forEach(function (link) {
|
||||
link.addEventListener('click', function (e) {
|
||||
var href = link.getAttribute('href');
|
||||
if (href === '#' || href === '') return;
|
||||
var target = document.querySelector(href);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
var headerHeight = document.getElementById('header').offsetHeight;
|
||||
var targetPos = target.getBoundingClientRect().top + window.pageYOffset - headerHeight + 1;
|
||||
window.scrollTo({
|
||||
top: targetPos,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Header Scroll Effect ==================== */
|
||||
function initHeaderScroll() {
|
||||
var header = document.getElementById('header');
|
||||
if (!header) return;
|
||||
window.addEventListener('scroll', function () {
|
||||
if (window.scrollY > 10) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Active Nav Highlighting ==================== */
|
||||
function initActiveNav() {
|
||||
var sections = document.querySelectorAll('section[id]');
|
||||
var navLinks = document.querySelectorAll('.nav-link');
|
||||
if (!sections.length || !navLinks.length) return;
|
||||
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
var id = entry.target.getAttribute('id');
|
||||
navLinks.forEach(function (link) {
|
||||
if (link.getAttribute('href') === '#' + id) {
|
||||
link.classList.add('active');
|
||||
link.classList.remove('text-gray-600');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
link.classList.add('text-gray-600');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '-40% 0px -55% 0px'
|
||||
});
|
||||
|
||||
sections.forEach(function (section) {
|
||||
observer.observe(section);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Reveal Animations ==================== */
|
||||
function initRevealAnimations() {
|
||||
var elements = document.querySelectorAll('.reveal-up, .reveal-left, .reveal-right');
|
||||
if (!elements.length) return;
|
||||
|
||||
// Set animation delay from inline style
|
||||
elements.forEach(function (el) {
|
||||
var styleDelay = el.style.animationDelay;
|
||||
if (styleDelay) {
|
||||
el.style.setProperty('--reveal-delay', styleDelay);
|
||||
}
|
||||
});
|
||||
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('revealed');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
});
|
||||
|
||||
elements.forEach(function (el) {
|
||||
observer.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Counter Animation ==================== */
|
||||
function initCounters() {
|
||||
var counters = document.querySelectorAll('.counter');
|
||||
if (!counters.length) return;
|
||||
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
counters.forEach(function (counter) {
|
||||
observer.observe(counter);
|
||||
});
|
||||
}
|
||||
|
||||
function animateCounter(el) {
|
||||
var target = parseInt(el.getAttribute('data-target'), 10);
|
||||
var duration = 2000;
|
||||
var startTime = null;
|
||||
|
||||
function step(timestamp) {
|
||||
if (!startTime) startTime = timestamp;
|
||||
var progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
// Ease out cubic
|
||||
var eased = 1 - Math.pow(1 - progress, 3);
|
||||
var current = Math.floor(eased * target);
|
||||
el.textContent = current;
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
el.textContent = target;
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/* ==================== Back to Top ==================== */
|
||||
function initBackToTop() {
|
||||
var btn = document.getElementById('back-to-top');
|
||||
if (!btn) return;
|
||||
|
||||
window.addEventListener('scroll', function () {
|
||||
if (window.scrollY > 500) {
|
||||
btn.classList.add('visible');
|
||||
btn.classList.remove('opacity-0', 'invisible');
|
||||
} else {
|
||||
btn.classList.remove('visible');
|
||||
btn.classList.add('opacity-0', 'invisible');
|
||||
}
|
||||
});
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Contact Form ==================== */
|
||||
function initContactForm() {
|
||||
var form = document.getElementById('contact-form');
|
||||
if (!form) return;
|
||||
|
||||
var successMsg = document.getElementById('form-success');
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous errors
|
||||
var errorMessages = form.querySelectorAll('.error-msg');
|
||||
errorMessages.forEach(function (msg) { msg.classList.add('hidden'); });
|
||||
var inputs = form.querySelectorAll('.form-input');
|
||||
inputs.forEach(function (input) { input.classList.remove('error'); });
|
||||
|
||||
var isValid = true;
|
||||
|
||||
// Validate name
|
||||
var name = document.getElementById('name');
|
||||
if (!name.value.trim()) {
|
||||
showError(name, 'name');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate phone (Chinese phone: 11 digits starting with 1, or landline)
|
||||
var phone = document.getElementById('phone');
|
||||
var phoneVal = phone.value.trim();
|
||||
if (!phoneVal || !/^[\d\s\-+()]{7,20}$/.test(phoneVal)) {
|
||||
showError(phone, 'phone');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate email
|
||||
var email = document.getElementById('email');
|
||||
var emailVal = email.value.trim();
|
||||
if (!emailVal || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailVal)) {
|
||||
showError(email, 'email');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate company
|
||||
var company = document.getElementById('company');
|
||||
if (!company.value.trim()) {
|
||||
showError(company, 'company');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Validate message
|
||||
var message = document.getElementById('message');
|
||||
if (!message.value.trim()) {
|
||||
showError(message, 'message');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
// === Submit logic ===
|
||||
// 此处可对接后端API,示例:
|
||||
// fetch('/api/contact', { method: 'POST', body: new FormData(form) })
|
||||
// .then(res => res.json())
|
||||
// .then(data => { ... })
|
||||
// .catch(err => { ... });
|
||||
|
||||
// 模拟提交成功
|
||||
form.style.display = 'none';
|
||||
successMsg.classList.remove('hidden');
|
||||
|
||||
// 5秒后恢复表单
|
||||
setTimeout(function () {
|
||||
form.reset();
|
||||
form.style.display = '';
|
||||
successMsg.classList.add('hidden');
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
function showError(input, fieldId) {
|
||||
input.classList.add('error');
|
||||
var errorMsg = input.parentElement.querySelector('.error-msg');
|
||||
if (errorMsg) {
|
||||
errorMsg.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear error on input
|
||||
var allInputs = form.querySelectorAll('.form-input');
|
||||
allInputs.forEach(function (input) {
|
||||
input.addEventListener('input', function () {
|
||||
input.classList.remove('error');
|
||||
var errorMsg = input.parentElement.querySelector('.error-msg');
|
||||
if (errorMsg) errorMsg.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== Footer Year ==================== */
|
||||
function initFooterYear() {
|
||||
var yearEl = document.getElementById('current-year');
|
||||
if (yearEl) {
|
||||
yearEl.textContent = new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
Loading…
Reference in New Issue