This commit is contained in:
liubiren 2026-09-21 20:34:22 +08:00
parent f289955974
commit d41c1cbc84
11 changed files with 1928 additions and 66 deletions

View File

@ -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"帮我查询账户余额")

View File

@ -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

View File

@ -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,

View File

@ -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

235
玄潮官网/css/style.css Normal file
View File

@ -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;
}
}

1257
玄潮官网/index.html Normal file

File diff suppressed because it is too large Load Diff

307
玄潮官网/js/main.js Normal file
View File

@ -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();
}
}
})();