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 typing import AsyncGenerator
|
||||||
|
|
||||||
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
|
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolApproved
|
||||||
|
|
@ -388,10 +388,7 @@ class ConversationState(rx.State):
|
||||||
|
|
||||||
# 运行并流式输出事件
|
# 运行并流式输出事件
|
||||||
stream_events = run_stream_events(
|
stream_events = run_stream_events(
|
||||||
deps=Deps(
|
user_id=self.user_id,
|
||||||
account_id=conversation.workflow.deps["account_id"],
|
|
||||||
connection_pool=connection_pool,
|
|
||||||
),
|
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
message_history=message_history,
|
message_history=message_history,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
|
|
@ -696,10 +693,17 @@ class ConversationState(rx.State):
|
||||||
match type:
|
match type:
|
||||||
# 预定航班
|
# 预定航班
|
||||||
case WorkflowType.BOOK_FLIGHT:
|
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(
|
self.set_user_prompt(
|
||||||
f"帮我查询并预定在 {workflow.deps.date.strftime('%Y-%m-%d')} 从 {workflow.deps.origin_airport_code} 到 {workflow.deps.destination_airport_code} 的航班"
|
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:
|
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"帮我查询账户余额")
|
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 import BaseModel, Field
|
||||||
from pydantic_ai import (
|
from pydantic_ai import (
|
||||||
Agent,
|
Agent,
|
||||||
|
|
@ -18,6 +17,7 @@ from pydantic_ai import (
|
||||||
from pydantic_ai.messages import AgentStreamEvent
|
from pydantic_ai.messages import AgentStreamEvent
|
||||||
from pydantic_ai.run import AgentRunResultEvent
|
from pydantic_ai.run import AgentRunResultEvent
|
||||||
|
|
||||||
|
from application.workshop.database import connection_pool
|
||||||
from application.workshop.models import (
|
from application.workshop.models import (
|
||||||
DEEPSEEK_V4_FLASH_CHAT_MODEL,
|
DEEPSEEK_V4_FLASH_CHAT_MODEL,
|
||||||
DEEPSEEK_V4_FLASH_CHAT_MODEL_SETTINGS,
|
DEEPSEEK_V4_FLASH_CHAT_MODEL_SETTINGS,
|
||||||
|
|
@ -29,48 +29,51 @@ class Deps(BaseModel):
|
||||||
依赖类
|
依赖类
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = {"arbitrary_types_allowed": True} # 允许任意类型
|
user_id: str = Field(..., description="用户唯一标识")
|
||||||
|
|
||||||
account_id: str = Field(..., description="账户唯一标识")
|
|
||||||
connection_pool: Pool = Field(..., description="连接池")
|
|
||||||
|
|
||||||
|
|
||||||
# 账户上下文能力
|
# 用户上下文能力
|
||||||
account_context = Capability[Deps](
|
user_context = Capability[Deps](
|
||||||
id="account-context",
|
id="user-context",
|
||||||
description="提供账户信息,用于客服应答",
|
description="提供用户信息,用于客服应答",
|
||||||
defer_loading=False, # 延迟加载置为否,立即加载
|
defer_loading=False, # 延迟加载置为否(立即加载)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@account_context.instructions
|
@user_context.instructions
|
||||||
async def query_account_name(ctx: RunContext[Deps]) -> str:
|
async def query_user_name(ctx: RunContext[Deps]) -> str:
|
||||||
"""
|
"""
|
||||||
查询账户户名
|
查询用户姓名
|
||||||
"""
|
"""
|
||||||
async with ctx.deps.connection_pool.acquire() as conn:
|
if connection_pool is None:
|
||||||
account_name = await conn.fetchval(
|
return "数据库连接池未初始化"
|
||||||
"SELECT account_name FROM bank_customer_service WHERE account_id = $1",
|
|
||||||
ctx.deps.account_id,
|
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:
|
if user_name is None:
|
||||||
return "该账户不存在"
|
return "该用户不存在"
|
||||||
return f"该账户户名是 {account_name!r}"
|
return f"用户姓名是 {user_name!r}"
|
||||||
|
|
||||||
|
|
||||||
@account_context.tool(name="查询账户余额")
|
@user_context.tool(name="查询账户余额")
|
||||||
async def query_account_balance(ctx: RunContext[Deps]) -> str:
|
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(
|
account_balance = await conn.fetchval(
|
||||||
"SELECT account_balance FROM bank_customer_service WHERE account_id = $1",
|
"SELECT account_balance FROM bank_customer_service WHERE user_id = $1",
|
||||||
ctx.deps.account_id,
|
ctx.deps.user_id,
|
||||||
)
|
)
|
||||||
if account_balance is None:
|
if account_balance is None:
|
||||||
return "该账户不存在"
|
return "该用户不存在"
|
||||||
return f"该账户余额: {account_balance:.2f}"
|
return f"该用户的账户余额: {account_balance:.2f}"
|
||||||
|
|
||||||
|
|
||||||
# 智能体
|
# 智能体
|
||||||
|
|
@ -80,7 +83,7 @@ agent = Agent[Deps, str](
|
||||||
deps_type=Deps,
|
deps_type=Deps,
|
||||||
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
||||||
instructions="你是我方银行的客服智能体,为客户提供客服应答。", # 优先使用 instructions
|
instructions="你是我方银行的客服智能体,为客户提供客服应答。", # 优先使用 instructions
|
||||||
capabilities=[account_context],
|
capabilities=[user_context],
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -131,7 +134,7 @@ PartEndEvent(index=0, part=TextPart(content='您好,刘弼仁先生。您账
|
||||||
|
|
||||||
|
|
||||||
async def run_stream_events(
|
async def run_stream_events(
|
||||||
deps: Any,
|
user_id: str,
|
||||||
user_prompt: str | None,
|
user_prompt: str | None,
|
||||||
message_history: list[ModelMessage],
|
message_history: list[ModelMessage],
|
||||||
usage: RunUsage,
|
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(
|
async with agent.run_stream_events(
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
deps=deps,
|
deps=Deps(user_id=user_id),
|
||||||
message_history=message_history,
|
message_history=message_history,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
usage_limits=usage_limits,
|
usage_limits=usage_limits,
|
||||||
deferred_tool_results=deferred_tool_results,
|
deferred_tool_results=deferred_tool_results,
|
||||||
) as events:
|
) as events:
|
||||||
async for event in events:
|
async for event in events:
|
||||||
print(event)
|
|
||||||
yield event
|
yield event
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"""
|
"""
|
||||||
预定航班工作流(范式)
|
预定航班工作流(范式)
|
||||||
"""
|
"""
|
||||||
import datetime
|
from datetime import date as Date
|
||||||
from typing import Any, AsyncGenerator, cast
|
from typing import Any, AsyncGenerator, cast
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
@ -39,7 +39,7 @@ class Flight(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
number: str = Field(..., description="航班号")
|
number: str = Field(..., description="航班号")
|
||||||
date: datetime.date = Field(..., description="日期")
|
date: Date = Field(..., description="日期")
|
||||||
origin_airport_code: str = Field(..., description="出发机场代码")
|
origin_airport_code: str = Field(..., description="出发机场代码")
|
||||||
destination_airport_code: str = Field(..., description="到达机场代码")
|
destination_airport_code: str = Field(..., description="到达机场代码")
|
||||||
airfare: int = 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="出发机场代码")
|
origin_airport_code: str = Field(..., description="出发机场代码")
|
||||||
destination_airport_code: str = Field(..., description="到达机场代码")
|
destination_airport_code: str = Field(..., description="到达机场代码")
|
||||||
searched_flights: list[Flight] | None = Field(
|
searched_flights: list[Flight] | None = Field(
|
||||||
|
|
@ -277,13 +277,6 @@ async def validate_output(
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
deps = Deps(
|
|
||||||
date=datetime.date(2025, 1, 10),
|
|
||||||
origin_airport_code="SFO",
|
|
||||||
destination_airport_code="ANC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_stream_events(
|
async def run_stream_events(
|
||||||
deps: Any,
|
deps: Any,
|
||||||
user_prompt: str | None,
|
user_prompt: str | None,
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,27 @@
|
||||||
研究
|
研究
|
||||||
"""
|
"""
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
from asyncpg import Pool
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, cast
|
||||||
from pydantic_ai import Agent, ModelMessage, RunContext, RunUsage
|
from pydantic_ai import Agent, ModelMessage, RunContext
|
||||||
|
from pydantic_ai.usage import RunUsage
|
||||||
from pydantic_ai.messages import AgentStreamEvent
|
from pydantic_ai.messages import AgentStreamEvent
|
||||||
from pydantic_ai.run import AgentRunResultEvent
|
from pydantic_ai.run import AgentRunResultEvent
|
||||||
from pydantic_ai.toolsets import FunctionToolset
|
from pydantic_ai.toolsets import FunctionToolset
|
||||||
|
from sqlmodel import REAL
|
||||||
|
|
||||||
from application.workshop.models import DEEPSEEK_V4_FLASH_RESPONSE_MODEL
|
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):
|
class Args(BaseModel):
|
||||||
|
|
@ -22,11 +34,11 @@ class Args(BaseModel):
|
||||||
bocha_web_search = FunctionToolset()
|
bocha_web_search = FunctionToolset()
|
||||||
|
|
||||||
|
|
||||||
@bocha_web_search.tool_plain() # 普通工具:不使用运行上下文
|
@bocha_web_search.tool(name="联网搜索") # type: ignore
|
||||||
async def web_search(args: Args) -> str:
|
async def web_search(ctx: RunContext[Deps], args: Args) -> str:
|
||||||
"""
|
"""
|
||||||
联网搜索工具:
|
联网搜索工具:
|
||||||
用于查询互联网实时信息、新闻、政策、行业报告、企业公告、最新数据。
|
用于查询互联网实时信息、新闻、政策法规、行业报告、企业公告、最新数据。
|
||||||
适合查询模型静态知识库不存在的外部网页信息。
|
适合查询模型静态知识库不存在的外部网页信息。
|
||||||
回答时必须标注引用来源URL,禁止编造链接。
|
回答时必须标注引用来源URL,禁止编造链接。
|
||||||
不要用于简单常识、数学计算、代码编写等无需联网的任务。
|
不要用于简单常识、数学计算、代码编写等无需联网的任务。
|
||||||
|
|
@ -89,17 +101,66 @@ async def web_search(args: Args) -> str:
|
||||||
|
|
||||||
instruction = """
|
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,
|
model=DEEPSEEK_V4_FLASH_RESPONSE_MODEL,
|
||||||
|
deps_type=Deps,
|
||||||
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
||||||
instructions=instruction,
|
instructions=instruction,
|
||||||
toolsets=[bocha_web_search],
|
toolsets=[bocha_web_search],
|
||||||
|
|
@ -118,7 +179,11 @@ async def run_stream_events(
|
||||||
:param usage: 使用量
|
:param usage: 使用量
|
||||||
:return: AsyncGenerator
|
:return: AsyncGenerator
|
||||||
"""
|
"""
|
||||||
|
if connection_pool is None:
|
||||||
|
return
|
||||||
|
|
||||||
async with agent.run_stream_events(
|
async with agent.run_stream_events(
|
||||||
|
deps=Deps(connection_pool=connection_pool),
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
message_history=message_history,
|
message_history=message_history,
|
||||||
usage=usage,
|
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