fix:优化数据
This commit is contained in:
@@ -1,14 +1,19 @@
|
|||||||
|
# 分层构建:依赖与代码分离,仅代码变更时只重建最后一层,加快迭代
|
||||||
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim
|
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim
|
||||||
|
|
||||||
ENV MODULE_NAME=backend.app.main
|
ENV MODULE_NAME=backend.app.main
|
||||||
ENV VARIABLE_NAME=app
|
ENV VARIABLE_NAME=app
|
||||||
ENV PORT=8000
|
ENV PORT=8000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
# SQLite 对并发写不友好,单 worker 避免多进程竞争
|
||||||
|
ENV WEB_CONCURRENCY=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 依赖层:只有 requirements 变更时才重建
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
# 代码层:仅此层会随业务代码变更而重建
|
||||||
COPY backend /app/backend
|
COPY backend /app/backend
|
||||||
|
|
||||||
|
|||||||
901
backend.log
Normal file
901
backend.log
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
INFO: Will watch for changes in these directories: ['/Users/dannier/Desktop/living/AiTool']
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||||
|
INFO: Started reloader process [85187] using WatchFiles
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/conftest.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_where.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_repr.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_reduction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_concat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_reshape.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_dtypes.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_comparison.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_function.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_formats.py'. Reloading...
|
||||||
|
Process SpawnProcess-1:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_libs/window/__init__.py'. Reloading...
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_freq_attr.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_legend.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_delete.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta_range.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_formats.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_searchsorted.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_ops.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_subplots.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_scalar_compat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_join.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/_libs/tslibs/__init__.py'. Reloading...
|
||||||
|
Process SpawnProcess-3:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_categorical.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_api.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply_mutate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_dropna.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_color.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_grouping.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_libgroupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_timegrouper.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_reductions.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numeric_only.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_all_methods.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_index_as_string.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_pipe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/conftest.py'. Reloading...
|
||||||
|
Process SpawnProcess-4:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_subclass.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_counting.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/multi/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_construction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_filters.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexing/multiindex/test_chaining_and_caching.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_raises.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_cumulative.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_equals.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_missing.py'. Reloading...
|
||||||
|
Process SpawnProcess-5:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_fillna.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_insert.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_is_monotonic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_corrwith.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_sample.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_value_counts.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_quantile.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_skew.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_transform.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_cython.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_describe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_factorize.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_other.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nth.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_aggregate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_groupby_shift_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_rank.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nlargest_nsmallest.py', '.venv/lib/python3.9/site-packages/pandas/tests/tslibs/test_fields.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_repeat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_shift.py'. Reloading...
|
||||||
|
Process SpawnProcess-6:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/attr/_version_info.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/_core.py', '.venv/lib/python3.9/site-packages/mdurl/__init__.py', '.venv/lib/python3.9/site-packages/attr/_next_gen.py', '.venv/lib/python3.9/site-packages/mdurl/_encode.py', '.venv/lib/python3.9/site-packages/attr/exceptions.py', '.venv/lib/python3.9/site-packages/mdurl/_parse.py', '.venv/lib/python3.9/site-packages/mdurl/_url.py', '.venv/lib/python3.9/site-packages/attr/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_format.py', '.venv/lib/python3.9/site-packages/attr/_compat.py', '.venv/lib/python3.9/site-packages/attr/converters.py', '.venv/lib/python3.9/site-packages/attr/_funcs.py', '.venv/lib/python3.9/site-packages/attr/validators.py', '.venv/lib/python3.9/site-packages/attr/filters.py', '.venv/lib/python3.9/site-packages/attr/_make.py', '.venv/lib/python3.9/site-packages/attr/setters.py', '.venv/lib/python3.9/site-packages/attr/_config.py', '.venv/lib/python3.9/site-packages/six.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_decode.py', '.venv/lib/python3.9/site-packages/rpds/__init__.py'. Reloading...
|
||||||
|
Process SpawnProcess-7:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
Process SpawnProcess-8:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_config/dates.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_bin_groupby.py', '.venv/lib/python3.9/site-packages/mako/codegen.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_getitem.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_hist_box_by.py', '.venv/lib/python3.9/site-packages/pandas/tests/construction/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/object/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_to_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/__init__.py', '.venv/lib/python3.9/site-packages/mako/exceptions.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_where.py', '.venv/lib/python3.9/site-packages/pandas/_config/localization.py', '.venv/lib/python3.9/site-packages/mako/lexer.py'. Reloading...
|
||||||
|
Process SpawnProcess-9:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/api/indexers/__init__.py', '.venv/lib/python3.9/site-packages/pandas/errors/__init__.py', '.venv/lib/python3.9/site-packages/lxml/ElementInclude.py'. Reloading...
|
||||||
|
Process SpawnProcess-10:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_content.py', '.venv/lib/python3.9/site-packages/mako/pyparser.py', '.venv/lib/python3.9/site-packages/mako/parsetree.py', '.venv/lib/python3.9/site-packages/mako/testing/_config.py', '.venv/lib/python3.9/site-packages/mako/_ast_util.py', '.venv/lib/python3.9/site-packages/lxml/sax.py', '.venv/lib/python3.9/site-packages/lxml/html/usedoctest.py', '.venv/lib/python3.9/site-packages/mako/testing/config.py', '.venv/lib/python3.9/site-packages/mako/testing/exclusions.py', '.venv/lib/python3.9/site-packages/mako/pygen.py', '.venv/lib/python3.9/site-packages/mako/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/__init__.py', '.venv/lib/python3.9/site-packages/mako/filters.py', '.venv/lib/python3.9/site-packages/mako/testing/fixtures.py', '.venv/lib/python3.9/site-packages/httpx/_types.py', '.venv/lib/python3.9/site-packages/httpx/_decoders.py', '.venv/lib/python3.9/site-packages/mako/cmd.py', '.venv/lib/python3.9/site-packages/mako/testing/assertions.py', '.venv/lib/python3.9/site-packages/httpx/_client.py', '.venv/lib/python3.9/site-packages/httpx/_urlparse.py', '.venv/lib/python3.9/site-packages/lxml/html/_setmixin.py', '.venv/lib/python3.9/site-packages/mako/lookup.py', '.venv/lib/python3.9/site-packages/mako/cache.py', '.venv/lib/python3.9/site-packages/httpx/_config.py', '.venv/lib/python3.9/site-packages/httptools/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_models.py', '.venv/lib/python3.9/site-packages/lxml/html/defs.py', '.venv/lib/python3.9/site-packages/httpx/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/helpers.py', '.venv/lib/python3.9/site-packages/mako/util.py', '.venv/lib/python3.9/site-packages/lxml/html/_difflib.py', '.venv/lib/python3.9/site-packages/mako/runtime.py', '.venv/lib/python3.9/site-packages/httpx/_status_codes.py', '.venv/lib/python3.9/site-packages/mako/template.py', '.venv/lib/python3.9/site-packages/mako/ast.py', '.venv/lib/python3.9/site-packages/mako/compat.py', '.venv/lib/python3.9/site-packages/lxml/doctestcompare.py', '.venv/lib/python3.9/site-packages/lxml/pyclasslookup.py', '.venv/lib/python3.9/site-packages/httpx/_auth.py'. Reloading...
|
||||||
|
Process SpawnProcess-11:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/mako/ext/preprocessors.py', '.venv/lib/python3.9/site-packages/mako/ext/__init__.py', '.venv/lib/python3.9/site-packages/lxml/_elementpath.py', '.venv/lib/python3.9/site-packages/mako/ext/autohandler.py', '.venv/lib/python3.9/site-packages/lxml/html/soupparser.py', '.venv/lib/python3.9/site-packages/lxml/html/html5parser.py', '.venv/lib/python3.9/site-packages/mako/ext/extract.py', '.venv/lib/python3.9/site-packages/lxml/html/ElementSoup.py', '.venv/lib/python3.9/site-packages/cachetools/func.py', '.venv/lib/python3.9/site-packages/cachetools/keys.py', '.venv/lib/python3.9/site-packages/mako/ext/beaker_cache.py', '.venv/lib/python3.9/site-packages/mako/ext/babelplugin.py', '.venv/lib/python3.9/site-packages/cachetools/_decorators.py', '.venv/lib/python3.9/site-packages/mako/ext/pygmentplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/formfill.py', '.venv/lib/python3.9/site-packages/lxml/html/clean.py', '.venv/lib/python3.9/site-packages/jiter/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/protocol.py', '.venv/lib/python3.9/site-packages/mako/ext/turbogears.py', '.venv/lib/python3.9/site-packages/mako/ext/linguaplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/_diffcommand.py', '.venv/lib/python3.9/site-packages/cachetools/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/diff.py'. Reloading...
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/altair/theme.py'. Reloading...
|
||||||
|
Process SpawnProcess-12:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/lxml/usedoctest.py', '.venv/lib/python3.9/site-packages/altair/typing/__init__.py', '.venv/lib/python3.9/site-packages/lxml/includes/extlibs/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/errors.py', '.venv/lib/python3.9/site-packages/httpx/_exceptions.py', '.venv/lib/python3.9/site-packages/lxml/isoschematron/__init__.py', '.venv/lib/python3.9/site-packages/lxml/__init__.py'. Reloading...
|
||||||
|
Process SpawnProcess-13:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
Process SpawnProcess-14:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_multipart.py', '.venv/lib/python3.9/site-packages/httpx/_main.py', '.venv/lib/python3.9/site-packages/lxml/builder.py', '.venv/lib/python3.9/site-packages/httptools/parser/__init__.py', '.venv/lib/python3.9/site-packages/lxml/cssselect.py', '.venv/lib/python3.9/site-packages/httpx/_api.py', '.venv/lib/python3.9/site-packages/httpx/__version__.py', '.venv/lib/python3.9/site-packages/httpx/_utils.py', '.venv/lib/python3.9/site-packages/lxml/includes/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/_html5builder.py', '.venv/lib/python3.9/site-packages/httpx/_compat.py'. Reloading...
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/sjisprober.py', '.venv/lib/python3.9/site-packages/chardet/jisfreq.py', '.venv/lib/python3.9/site-packages/chardet/resultdict.py', '.venv/lib/python3.9/site-packages/chardet/sbcharsetprober.py', '.venv/lib/python3.9/site-packages/chardet/escprober.py', '.venv/lib/python3.9/site-packages/chardet/version.py', '.venv/lib/python3.9/site-packages/lxml/includes/libexslt/__init__.py', '.venv/lib/python3.9/site-packages/chardet/langgreekmodel.py', '.venv/lib/python3.9/site-packages/chardet/codingstatemachine.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxslt/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/__init__.py'. Reloading...
|
||||||
|
Process SpawnProcess-15:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
Process SpawnProcess-16:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_urls.py', '.venv/lib/python3.9/site-packages/streamlit/error_util.py', '.venv/lib/python3.9/site-packages/lxml/html/builder.py', '.venv/lib/python3.9/site-packages/git/index/fun.py', '.venv/lib/python3.9/site-packages/chardet/universaldetector.py', '.venv/lib/python3.9/site-packages/streamlit/__init__.py', '.venv/lib/python3.9/site-packages/httptools/_version.py', '.venv/lib/python3.9/site-packages/chardet/big5prober.py', '.venv/lib/python3.9/site-packages/altair/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/url_util.py', '.venv/lib/python3.9/site-packages/chardet/langrussianmodel.py', '.venv/lib/python3.9/site-packages/altair/_magics.py'. Reloading...
|
||||||
|
Process SpawnProcess-17:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/utf8prober.py', '.venv/lib/python3.9/site-packages/chardet/latin1prober.py', '.venv/lib/python3.9/site-packages/altair/utils/plugin_registry.py', '.venv/lib/python3.9/site-packages/altair/utils/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/mimebundle.py', '.venv/lib/python3.9/site-packages/chardet/charsetprober.py', '.venv/lib/python3.9/site-packages/chardet/jpcntx.py', '.venv/lib/python3.9/site-packages/chardet/gb2312freq.py', '.venv/lib/python3.9/site-packages/chardet/langhebrewmodel.py', '.venv/lib/python3.9/site-packages/altair/expr/core.py', '.venv/lib/python3.9/site-packages/altair/utils/html.py', '.venv/lib/python3.9/site-packages/chardet/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcharsetprober.py', '.venv/lib/python3.9/site-packages/altair/utils/display.py', '.venv/lib/python3.9/site-packages/httpx/_transports/wsgi.py', '.venv/lib/python3.9/site-packages/altair/utils/selection.py', '.venv/lib/python3.9/site-packages/altair/expr/consts.py', '.venv/lib/python3.9/site-packages/altair/jupyter/jupyter_chart.py', '.venv/lib/python3.9/site-packages/altair/utils/server.py', '.venv/lib/python3.9/site-packages/altair/expr/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_transports/asgi.py', '.venv/lib/python3.9/site-packages/httpx/_transports/default.py', '.venv/lib/python3.9/site-packages/altair/utils/deprecation.py', '.venv/lib/python3.9/site-packages/altair/utils/_transformed_data.py', '.venv/lib/python3.9/site-packages/chardet/__main__.py', '.venv/lib/python3.9/site-packages/chardet/big5freq.py', '.venv/lib/python3.9/site-packages/chardet/langhungarianmodel.py', '.venv/lib/python3.9/site-packages/httpx/_transports/__init__.py', '.venv/lib/python3.9/site-packages/chardet/euckrprober.py', '.venv/lib/python3.9/site-packages/chardet/langthaimodel.py', '.venv/lib/python3.9/site-packages/chardet/gb2312prober.py', '.venv/lib/python3.9/site-packages/altair/utils/schemapi.py', '.venv/lib/python3.9/site-packages/altair/utils/_dfi_types.py', '.venv/lib/python3.9/site-packages/chardet/johabprober.py', '.venv/lib/python3.9/site-packages/chardet/euctwfreq.py', '.venv/lib/python3.9/site-packages/chardet/euctwprober.py', '.venv/lib/python3.9/site-packages/altair/jupyter/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/_importers.py', '.venv/lib/python3.9/site-packages/chardet/charsetgroupprober.py', '.venv/lib/python3.9/site-packages/httpx/_transports/base.py', '.venv/lib/python3.9/site-packages/altair/utils/compiler.py', '.venv/lib/python3.9/site-packages/altair/utils/_vegafusion_data.py', '.venv/lib/python3.9/site-packages/chardet/sbcsgroupprober.py', '.venv/lib/python3.9/site-packages/altair/utils/data.py', '.venv/lib/python3.9/site-packages/altair/expr/funcs.py', '.venv/lib/python3.9/site-packages/chardet/euckrfreq.py', '.venv/lib/python3.9/site-packages/chardet/escsm.py', '.venv/lib/python3.9/site-packages/altair/utils/execeval.py', '.venv/lib/python3.9/site-packages/altair/utils/save.py', '.venv/lib/python3.9/site-packages/chardet/langturkishmodel.py', '.venv/lib/python3.9/site-packages/altair/utils/core.py', '.venv/lib/python3.9/site-packages/altair/utils/_show.py', '.venv/lib/python3.9/site-packages/chardet/johabfreq.py', '.venv/lib/python3.9/site-packages/httpx/_transports/mock.py', '.venv/lib/python3.9/site-packages/chardet/langbulgarianmodel.py', '.venv/lib/python3.9/site-packages/chardet/utf1632prober.py'. Reloading...
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/codingstatemachinedict.py', '.venv/lib/python3.9/site-packages/git/config.py', '.venv/lib/python3.9/site-packages/chardet/macromanprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/incremental_tree.py', '.venv/lib/python3.9/site-packages/chardet/chardistribution.py', '.venv/lib/python3.9/site-packages/altair/vegalite/api.py', '.venv/lib/python3.9/site-packages/distro/distro.py', '.venv/lib/python3.9/site-packages/et_xmlfile/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcssm.py', '.venv/lib/python3.9/site-packages/distro/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cp949prober.py', '.venv/lib/python3.9/site-packages/altair/vegalite/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/schema.py', '.venv/lib/python3.9/site-packages/distro/__main__.py', '.venv/lib/python3.9/site-packages/chardet/enums.py', '.venv/lib/python3.9/site-packages/altair/vegalite/display.py', '.venv/lib/python3.9/site-packages/chardet/mbcsgroupprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/xmlfile.py', '.venv/lib/python3.9/site-packages/chardet/eucjpprober.py', '.venv/lib/python3.9/site-packages/chardet/hebrewprober.py'. Reloading...
|
||||||
|
Process SpawnProcess-18:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/__init__.py', '.venv/lib/python3.9/site-packages/git/types.py', '.venv/lib/python3.9/site-packages/git/diff.py', '.venv/lib/python3.9/site-packages/git/cmd.py', '.venv/lib/python3.9/site-packages/git/exc.py', '.venv/lib/python3.9/site-packages/git/util.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxml/__init__.py', '.venv/lib/python3.9/site-packages/git/remote.py', '.venv/lib/python3.9/site-packages/git/compat.py', '.venv/lib/python3.9/site-packages/git/db.py'. Reloading...
|
||||||
|
Process SpawnProcess-19:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
Process SpawnProcess-20:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/metadata/__init__.py', '.venv/lib/python3.9/site-packages/chardet/metadata/languages.py', '.venv/lib/python3.9/site-packages/git/refs/symbolic.py', '.venv/lib/python3.9/site-packages/git/refs/reference.py', '.venv/lib/python3.9/site-packages/git/repo/fun.py', '.venv/lib/python3.9/site-packages/git/refs/remote.py', '.venv/lib/python3.9/site-packages/git/repo/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/head.py', '.venv/lib/python3.9/site-packages/git/objects/blob.py', '.venv/lib/python3.9/site-packages/git/objects/tag.py', '.venv/lib/python3.9/site-packages/chardet/cli/chardetect.py', '.venv/lib/python3.9/site-packages/git/refs/log.py', '.venv/lib/python3.9/site-packages/git/refs/tag.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/theme.py', '.venv/lib/python3.9/site-packages/git/objects/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cli/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/api.py', '.venv/lib/python3.9/site-packages/git/objects/fun.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/channels.py', '.venv/lib/python3.9/site-packages/git/objects/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/compiler.py', '.venv/lib/python3.9/site-packages/git/objects/commit.py', '.venv/lib/python3.9/site-packages/git/objects/tree.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/display.py', '.venv/lib/python3.9/site-packages/git/repo/__init__.py', '.venv/lib/python3.9/site-packages/git/objects/util.py'. Reloading...
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/index/__init__.py', '.venv/lib/python3.9/site-packages/blinker/base.py', '.venv/lib/python3.9/site-packages/git/index/typ.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/core.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/mixins.py', '.venv/lib/python3.9/site-packages/git/index/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_typing.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_config.py', '.venv/lib/python3.9/site-packages/git/index/util.py'. Reloading...
|
||||||
|
Process SpawnProcess-21:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/exception.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/subtitle_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/streamlit_plotly_theme.py', '.venv/lib/python3.9/site-packages/streamlit/config_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/options_selector_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/code.py', '.venv/lib/python3.9/site-packages/streamlit/elements/balloons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/mutable_status_container.py', '.venv/lib/python3.9/site-packages/streamlit/elements/write.py', '.venv/lib/python3.9/site-packages/streamlit/hello/mapping_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/policies.py', '.venv/lib/python3.9/site-packages/streamlit/development.py', '.venv/lib/python3.9/site-packages/streamlit/type_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/form_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/vega_charts.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/page.py', '.venv/lib/python3.9/site-packages/streamlit/elements/plotly_chart.py', '.venv/lib/python3.9/site-packages/streamlit/hello/utils.py', '.venv/lib/python3.9/site-packages/streamlit/external/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/heading.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/js_number.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/column_config_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/alert.py', '.venv/lib/python3.9/site-packages/streamlit/time_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/pyplot.py', '.venv/lib/python3.9/site-packages/streamlit/hello/dataframe_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/snow.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator_singletons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/arrow.py', '.venv/lib/python3.9/site-packages/streamlit/elements/form.py', '.venv/lib/python3.9/site-packages/streamlit/deprecation_util.py', '.venv/lib/python3.9/site-packages/streamlit/temporary_directory.py', '.venv/lib/python3.9/site-packages/blinker/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/hello/plotting_demo.py', '.venv/lib/python3.9/site-packages/streamlit/cursor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/color_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/dialog_decorator.py', '.venv/lib/python3.9/site-packages/streamlit/commands/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/json.py', '.venv/lib/python3.9/site-packages/streamlit/hello/hello.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/pandas_styler_utils.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/version.py', '.venv/lib/python3.9/site-packages/streamlit/elements/toast.py', '.venv/lib/python3.9/site-packages/streamlit/config_option.py', '.venv/lib/python3.9/site-packages/streamlit/emojis.py', '.venv/lib/python3.9/site-packages/streamlit/commands/logo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/logger.py', '.venv/lib/python3.9/site-packages/blinker/_utilities.py', '.venv/lib/python3.9/site-packages/streamlit/elements/bokeh_chart.py', '.venv/lib/python3.9/site-packages/streamlit/material_icon_names.py', '.venv/lib/python3.9/site-packages/streamlit/elements/text.py', '.venv/lib/python3.9/site-packages/streamlit/__main__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/graphviz_chart.py', '.venv/lib/python3.9/site-packages/streamlit/file_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/html.py', '.venv/lib/python3.9/site-packages/streamlit/string_util.py', '.venv/lib/python3.9/site-packages/streamlit/dataframe_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/spinner.py', '.venv/lib/python3.9/site-packages/streamlit/env_util.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator.py', '.venv/lib/python3.9/site-packages/streamlit/elements/progress.py'. Reloading...
|
||||||
|
Process SpawnProcess-22:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
Process SpawnProcess-23:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/select_slider.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_storage.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/file_uploader.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/credentials.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/radio.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/audio_input.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/data_editor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/time_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_uploaded_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/text_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/connection_factory.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/fragment.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/pages_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/dialog.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/number_input.py', '.venv/lib/python3.9/site-packages/streamlit/commands/experimental_query_params.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/metrics_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/metric.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/checkbox.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/forward_msg_cache.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/chat.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/script_data.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_session_storage.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/websocket_session_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/stats.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button_group.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/secrets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/uploaded_file_manager.py'. Reloading...
|
||||||
|
Process SpawnProcess-24:
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
|
||||||
|
self.run()
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
|
||||||
|
self._target(*self._args, **self._kwargs)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
|
||||||
|
target(sockets=sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
|
||||||
|
return asyncio.run(self.serve(sockets=sockets))
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
|
||||||
|
return loop.run_until_complete(main)
|
||||||
|
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
|
||||||
|
await self._serve(sockets)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
|
||||||
|
config.load()
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
|
||||||
|
self.loaded_app = import_from_string(self.app)
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
|
||||||
|
module = importlib.import_module(module_str)
|
||||||
|
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
|
||||||
|
return _bootstrap._gcd_import(name[level:], package, level)
|
||||||
|
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
|
||||||
|
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
|
||||||
|
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
|
||||||
|
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
|
||||||
|
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
|
||||||
|
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
|
||||||
|
from backend.app import models
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
|
||||||
|
class Customer(Base):
|
||||||
|
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
|
||||||
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
||||||
|
INFO: Stopping reloader process [85187]
|
||||||
@@ -6,7 +6,19 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from backend.app.routers import customers, projects, finance
|
from backend.app.routers import (
|
||||||
|
customers,
|
||||||
|
projects,
|
||||||
|
finance,
|
||||||
|
settings,
|
||||||
|
ai_settings,
|
||||||
|
email_configs,
|
||||||
|
cloud_docs,
|
||||||
|
cloud_doc_config,
|
||||||
|
portal_links,
|
||||||
|
)
|
||||||
|
from backend.app.db import Base, engine
|
||||||
|
from backend.app import models # noqa: F401 - ensure models are imported
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -16,6 +28,35 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ensure database tables exist (especially when running in Docker)
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup() -> None:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Add new columns to finance_records if they don't exist (Module 6)
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
with engine.connect() as conn:
|
||||||
|
r = conn.execute(text("PRAGMA table_info(finance_records)"))
|
||||||
|
cols = [row[1] for row in r]
|
||||||
|
if "amount" not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)"))
|
||||||
|
if "billing_date" not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Add customers.tags if missing (customer tags for project 收纳)
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
with engine.connect() as conn:
|
||||||
|
r = conn.execute(text("PRAGMA table_info(customers)"))
|
||||||
|
cols = [row[1] for row in r]
|
||||||
|
if "tags" not in cols:
|
||||||
|
conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
raw_origins = os.getenv("CORS_ORIGINS")
|
raw_origins = os.getenv("CORS_ORIGINS")
|
||||||
if raw_origins:
|
if raw_origins:
|
||||||
@@ -35,6 +76,12 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(customers.router)
|
app.include_router(customers.router)
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(finance.router)
|
app.include_router(finance.router)
|
||||||
|
app.include_router(settings.router)
|
||||||
|
app.include_router(ai_settings.router)
|
||||||
|
app.include_router(email_configs.router)
|
||||||
|
app.include_router(cloud_docs.router)
|
||||||
|
app.include_router(cloud_doc_config.router)
|
||||||
|
app.include_router(portal_links.router)
|
||||||
|
|
||||||
# Static data mount (for quotes, contracts, finance archives, etc.)
|
# Static data mount (for quotes, contracts, finance archives, etc.)
|
||||||
data_dir = Path("data")
|
data_dir = Path("data")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Date,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -20,6 +21,7 @@ class Customer(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(512), nullable=True) # 逗号分隔,如:重点客户,已签约
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
@@ -51,6 +53,30 @@ class Project(Base):
|
|||||||
quotes: Mapped[list["Quote"]] = relationship(
|
quotes: Mapped[list["Quote"]] = relationship(
|
||||||
"Quote", back_populates="project", cascade="all, delete-orphan"
|
"Quote", back_populates="project", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
cloud_docs: Mapped[list["ProjectCloudDoc"]] = relationship(
|
||||||
|
"ProjectCloudDoc", back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCloudDoc(Base):
|
||||||
|
"""项目与云文档的映射,用于增量更新(有则 PATCH,无则 POST)。"""
|
||||||
|
__tablename__ = "project_cloud_docs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
platform: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # feishu | yuque | tencent
|
||||||
|
cloud_doc_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
|
cloud_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
project: Mapped["Project"] = relationship("Project", back_populates="cloud_docs")
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base):
|
class Quote(Base):
|
||||||
@@ -74,9 +100,11 @@ class FinanceRecord(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM
|
month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM
|
||||||
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / ...
|
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / manual / ...
|
||||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
300
backend/app/routers/ai_settings.py
Normal file
300
backend/app/routers/ai_settings.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
AI 模型配置:支持多套配置,持久化在 data/ai_configs.json,可选用当前生效配置。
|
||||||
|
GET /settings/ai 当前选用配置;GET /settings/ai/list 列表;POST 新增;PUT /:id 更新;DELETE /:id 删除;POST /:id/activate 选用。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.app.services.ai_service import get_active_ai_config, test_connection_with_config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/ai", tags=["ai-settings"])
|
||||||
|
|
||||||
|
CONFIGS_PATH = Path("data/ai_configs.json")
|
||||||
|
LEGACY_CONFIG_PATH = Path("data/ai_config.json")
|
||||||
|
|
||||||
|
DEFAULT_FIELDS: Dict[str, Any] = {
|
||||||
|
"provider": "OpenAI",
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"model_name": "gpt-4o-mini",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"system_prompt_override": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigRead(BaseModel):
|
||||||
|
model_config = {"protected_namespaces": ()}
|
||||||
|
|
||||||
|
id: str = ""
|
||||||
|
name: str = ""
|
||||||
|
provider: str = "OpenAI"
|
||||||
|
api_key: str = ""
|
||||||
|
base_url: str = ""
|
||||||
|
model_name: str = "gpt-4o-mini"
|
||||||
|
temperature: float = 0.2
|
||||||
|
system_prompt_override: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigListItem(BaseModel):
|
||||||
|
"""列表项:不含完整 api_key,仅标记是否已配置"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
provider: str
|
||||||
|
model_name: str
|
||||||
|
base_url: str = ""
|
||||||
|
api_key_configured: bool = False
|
||||||
|
is_active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigCreate(BaseModel):
|
||||||
|
model_config = {"protected_namespaces": ()}
|
||||||
|
|
||||||
|
name: str = Field("", max_length=64)
|
||||||
|
provider: str = "OpenAI"
|
||||||
|
api_key: str = ""
|
||||||
|
base_url: str = ""
|
||||||
|
model_name: str = "gpt-4o-mini"
|
||||||
|
temperature: float = 0.2
|
||||||
|
system_prompt_override: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AIConfigUpdate(BaseModel):
|
||||||
|
model_config = {"protected_namespaces": ()}
|
||||||
|
|
||||||
|
name: str | None = Field(None, max_length=64)
|
||||||
|
provider: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
model_name: str | None = None
|
||||||
|
temperature: float | None = None
|
||||||
|
system_prompt_override: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configs_file() -> Dict[str, Any]:
|
||||||
|
if not CONFIGS_PATH.exists():
|
||||||
|
return {"configs": [], "active_id": ""}
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIGS_PATH.read_text(encoding="utf-8"))
|
||||||
|
return {"configs": data.get("configs", []), "active_id": data.get("active_id", "") or ""}
|
||||||
|
except Exception:
|
||||||
|
return {"configs": [], "active_id": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_from_legacy() -> None:
|
||||||
|
if CONFIGS_PATH.exists():
|
||||||
|
return
|
||||||
|
if not LEGACY_CONFIG_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
legacy = json.loads(LEGACY_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
cfg = {**DEFAULT_FIELDS, **legacy}
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
payload = {
|
||||||
|
"configs": [
|
||||||
|
{
|
||||||
|
"id": new_id,
|
||||||
|
"name": "默认配置",
|
||||||
|
"provider": cfg.get("provider", "OpenAI"),
|
||||||
|
"api_key": cfg.get("api_key", ""),
|
||||||
|
"base_url": cfg.get("base_url", ""),
|
||||||
|
"model_name": cfg.get("model_name", "gpt-4o-mini"),
|
||||||
|
"temperature": cfg.get("temperature", 0.2),
|
||||||
|
"system_prompt_override": cfg.get("system_prompt_override", ""),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active_id": new_id,
|
||||||
|
}
|
||||||
|
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIGS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_configs(configs: List[Dict], active_id: str) -> None:
|
||||||
|
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIGS_PATH.write_text(
|
||||||
|
json.dumps({"configs": configs, "active_id": active_id}, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AIConfigRead)
|
||||||
|
async def get_current_ai_settings():
|
||||||
|
"""返回当前选用的 AI 配置(用于编辑表单与兼容旧接口)。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
cfg = get_active_ai_config()
|
||||||
|
return AIConfigRead(
|
||||||
|
id=cfg.get("id", ""),
|
||||||
|
name=cfg.get("name", ""),
|
||||||
|
provider=cfg.get("provider", "OpenAI"),
|
||||||
|
api_key=cfg.get("api_key", ""),
|
||||||
|
base_url=cfg.get("base_url", ""),
|
||||||
|
model_name=cfg.get("model_name", "gpt-4o-mini"),
|
||||||
|
temperature=float(cfg.get("temperature", 0.2)),
|
||||||
|
system_prompt_override=cfg.get("system_prompt_override", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=List[AIConfigListItem])
|
||||||
|
async def list_ai_configs():
|
||||||
|
"""列出所有已配置的模型,方便查看、选用或编辑。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
configs = data.get("configs") or []
|
||||||
|
active_id = data.get("active_id") or ""
|
||||||
|
out = []
|
||||||
|
for c in configs:
|
||||||
|
out.append(
|
||||||
|
AIConfigListItem(
|
||||||
|
id=c.get("id", ""),
|
||||||
|
name=c.get("name", "未命名"),
|
||||||
|
provider=c.get("provider", "OpenAI"),
|
||||||
|
model_name=c.get("model_name", ""),
|
||||||
|
base_url=(c.get("base_url") or "")[:64] or "",
|
||||||
|
api_key_configured=bool((c.get("api_key") or "").strip()),
|
||||||
|
is_active=(c.get("id") == active_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}", response_model=AIConfigRead)
|
||||||
|
async def get_ai_config_by_id(config_id: str):
|
||||||
|
"""获取单条配置(用于编辑)。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
for c in data.get("configs") or []:
|
||||||
|
if c.get("id") == config_id:
|
||||||
|
return AIConfigRead(
|
||||||
|
id=c.get("id", ""),
|
||||||
|
name=c.get("name", ""),
|
||||||
|
provider=c.get("provider", "OpenAI"),
|
||||||
|
api_key=c.get("api_key", ""),
|
||||||
|
base_url=c.get("base_url", ""),
|
||||||
|
model_name=c.get("model_name", "gpt-4o-mini"),
|
||||||
|
temperature=float(c.get("temperature", 0.2)),
|
||||||
|
system_prompt_override=c.get("system_prompt_override", ""),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AIConfigRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_ai_config(payload: AIConfigCreate):
|
||||||
|
"""新增一套模型配置。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
configs = list(data.get("configs") or [])
|
||||||
|
active_id = data.get("active_id") or ""
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
name = (payload.name or "").strip() or f"{payload.provider} - {payload.model_name}"
|
||||||
|
new_cfg = {
|
||||||
|
"id": new_id,
|
||||||
|
"name": name[:64],
|
||||||
|
"provider": payload.provider or "OpenAI",
|
||||||
|
"api_key": payload.api_key or "",
|
||||||
|
"base_url": (payload.base_url or "").strip(),
|
||||||
|
"model_name": (payload.model_name or "gpt-4o-mini").strip(),
|
||||||
|
"temperature": float(payload.temperature) if payload.temperature is not None else 0.2,
|
||||||
|
"system_prompt_override": (payload.system_prompt_override or "").strip(),
|
||||||
|
}
|
||||||
|
configs.append(new_cfg)
|
||||||
|
if not active_id:
|
||||||
|
active_id = new_id
|
||||||
|
_save_configs(configs, active_id)
|
||||||
|
return AIConfigRead(**new_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}", response_model=AIConfigRead)
|
||||||
|
async def update_ai_config(config_id: str, payload: AIConfigUpdate):
|
||||||
|
"""更新指定配置。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
configs = data.get("configs") or []
|
||||||
|
for c in configs:
|
||||||
|
if c.get("id") == config_id:
|
||||||
|
if payload.name is not None:
|
||||||
|
c["name"] = (payload.name or "").strip()[:64] or c.get("name", "")
|
||||||
|
if payload.provider is not None:
|
||||||
|
c["provider"] = payload.provider
|
||||||
|
if payload.api_key is not None:
|
||||||
|
c["api_key"] = payload.api_key
|
||||||
|
if payload.base_url is not None:
|
||||||
|
c["base_url"] = (payload.base_url or "").strip()
|
||||||
|
if payload.model_name is not None:
|
||||||
|
c["model_name"] = (payload.model_name or "").strip()
|
||||||
|
if payload.temperature is not None:
|
||||||
|
c["temperature"] = float(payload.temperature)
|
||||||
|
if payload.system_prompt_override is not None:
|
||||||
|
c["system_prompt_override"] = (payload.system_prompt_override or "").strip()
|
||||||
|
_save_configs(configs, data.get("active_id", ""))
|
||||||
|
return AIConfigRead(
|
||||||
|
id=c.get("id", ""),
|
||||||
|
name=c.get("name", ""),
|
||||||
|
provider=c.get("provider", "OpenAI"),
|
||||||
|
api_key=c.get("api_key", ""),
|
||||||
|
base_url=c.get("base_url", ""),
|
||||||
|
model_name=c.get("model_name", "gpt-4o-mini"),
|
||||||
|
temperature=float(c.get("temperature", 0.2)),
|
||||||
|
system_prompt_override=c.get("system_prompt_override", ""),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_ai_config(config_id: str):
|
||||||
|
"""删除指定配置;若为当前选用,则改用列表第一项。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
configs = [c for c in (data.get("configs") or []) if c.get("id") != config_id]
|
||||||
|
active_id = data.get("active_id", "")
|
||||||
|
if active_id == config_id:
|
||||||
|
active_id = configs[0].get("id", "") if configs else ""
|
||||||
|
_save_configs(configs, active_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_ai_connection(config_id: str | None = Query(None, description="指定配置 ID,不传则用当前选用")):
|
||||||
|
"""测试连接;不传 config_id 时使用当前选用配置。"""
|
||||||
|
if config_id:
|
||||||
|
data = _load_configs_file()
|
||||||
|
for c in data.get("configs") or []:
|
||||||
|
if c.get("id") == config_id:
|
||||||
|
try:
|
||||||
|
result = await test_connection_with_config(c)
|
||||||
|
return {"status": "ok", "message": result}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
|
try:
|
||||||
|
result = await test_connection_with_config(get_active_ai_config())
|
||||||
|
return {"status": "ok", "message": result}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{config_id}/activate", response_model=AIConfigRead)
|
||||||
|
async def activate_ai_config(config_id: str):
|
||||||
|
"""选用该配置为当前生效。"""
|
||||||
|
_migrate_from_legacy()
|
||||||
|
data = _load_configs_file()
|
||||||
|
exists = any(c.get("id") == config_id for c in (data.get("configs") or []))
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
|
_save_configs(data.get("configs", []), config_id)
|
||||||
|
cfg = get_active_ai_config()
|
||||||
|
return AIConfigRead(
|
||||||
|
id=cfg.get("id", ""),
|
||||||
|
name=cfg.get("name", ""),
|
||||||
|
provider=cfg.get("provider", "OpenAI"),
|
||||||
|
api_key=cfg.get("api_key", ""),
|
||||||
|
base_url=cfg.get("base_url", ""),
|
||||||
|
model_name=cfg.get("model_name", "gpt-4o-mini"),
|
||||||
|
temperature=float(cfg.get("temperature", 0.2)),
|
||||||
|
system_prompt_override=cfg.get("system_prompt_override", ""),
|
||||||
|
)
|
||||||
139
backend/app/routers/cloud_doc_config.py
Normal file
139
backend/app/routers/cloud_doc_config.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
云文档配置:各平台 API 凭证的存储与读取。
|
||||||
|
飞书 App ID/Secret、语雀 Token、腾讯文档 Client ID/Secret。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/cloud-doc-config", tags=["cloud-doc-config"])
|
||||||
|
|
||||||
|
CONFIG_PATH = Path("data/cloud_doc_credentials.json")
|
||||||
|
|
||||||
|
PLATFORMS = ("feishu", "yuque", "tencent")
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConfig(BaseModel):
|
||||||
|
app_id: str = Field("", description="飞书应用 App ID")
|
||||||
|
app_secret: str = Field("", description="飞书应用 App Secret")
|
||||||
|
|
||||||
|
|
||||||
|
class YuqueConfig(BaseModel):
|
||||||
|
token: str = Field("", description="语雀 Personal Access Token")
|
||||||
|
default_repo: str = Field("", description="默认知识库 namespace,如 my/repo")
|
||||||
|
|
||||||
|
|
||||||
|
class TencentConfig(BaseModel):
|
||||||
|
client_id: str = Field("", description="腾讯文档应用 Client ID")
|
||||||
|
client_secret: str = Field("", description="腾讯文档应用 Client Secret")
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConfigRead(BaseModel):
|
||||||
|
app_id: str = ""
|
||||||
|
app_secret_configured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class YuqueConfigRead(BaseModel):
|
||||||
|
token_configured: bool = False
|
||||||
|
default_repo: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TencentConfigRead(BaseModel):
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret_configured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDocConfigRead(BaseModel):
|
||||||
|
feishu: FeishuConfigRead
|
||||||
|
yuque: YuqueConfigRead
|
||||||
|
tencent: TencentConfigRead
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> Dict[str, Any]:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_config(data: Dict[str, Any]) -> None:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_secrets_for_read(raw: Dict[str, Any]) -> CloudDocConfigRead:
|
||||||
|
f = raw.get("feishu") or {}
|
||||||
|
y = raw.get("yuque") or {}
|
||||||
|
t = raw.get("tencent") or {}
|
||||||
|
return CloudDocConfigRead(
|
||||||
|
feishu=FeishuConfigRead(
|
||||||
|
app_id=f.get("app_id") or "",
|
||||||
|
app_secret_configured=bool((f.get("app_secret") or "").strip()),
|
||||||
|
),
|
||||||
|
yuque=YuqueConfigRead(
|
||||||
|
token_configured=bool((y.get("token") or "").strip()),
|
||||||
|
default_repo=(y.get("default_repo") or "").strip(),
|
||||||
|
),
|
||||||
|
tencent=TencentConfigRead(
|
||||||
|
client_id=t.get("client_id") or "",
|
||||||
|
client_secret_configured=bool((t.get("client_secret") or "").strip()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CloudDocConfigRead)
|
||||||
|
async def get_cloud_doc_config():
|
||||||
|
"""获取云文档配置(凭证以是否已配置返回,不返回明文)。"""
|
||||||
|
raw = _load_config()
|
||||||
|
return _mask_secrets_for_read(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=CloudDocConfigRead)
|
||||||
|
async def update_cloud_doc_config(payload: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
更新云文档配置。传各平台字段,未传的保留原值。
|
||||||
|
例: { "feishu": { "app_id": "xxx", "app_secret": "yyy" }, "yuque": { "token": "zzz", "default_repo": "a/b" } }
|
||||||
|
"""
|
||||||
|
raw = _load_config()
|
||||||
|
for platform in PLATFORMS:
|
||||||
|
if platform not in payload or not isinstance(payload[platform], dict):
|
||||||
|
continue
|
||||||
|
p = payload[platform]
|
||||||
|
if platform == "feishu":
|
||||||
|
if "app_id" in p and p["app_id"] is not None:
|
||||||
|
raw.setdefault("feishu", {})["app_id"] = str(p["app_id"]).strip()
|
||||||
|
if "app_secret" in p and p["app_secret"] is not None:
|
||||||
|
raw.setdefault("feishu", {})["app_secret"] = str(p["app_secret"]).strip()
|
||||||
|
elif platform == "yuque":
|
||||||
|
if "token" in p and p["token"] is not None:
|
||||||
|
raw.setdefault("yuque", {})["token"] = str(p["token"]).strip()
|
||||||
|
if "default_repo" in p and p["default_repo"] is not None:
|
||||||
|
raw.setdefault("yuque", {})["default_repo"] = str(p["default_repo"]).strip()
|
||||||
|
elif platform == "tencent":
|
||||||
|
if "client_id" in p and p["client_id"] is not None:
|
||||||
|
raw.setdefault("tencent", {})["client_id"] = str(p["client_id"]).strip()
|
||||||
|
if "client_secret" in p and p["client_secret"] is not None:
|
||||||
|
raw.setdefault("tencent", {})["client_secret"] = str(p["client_secret"]).strip()
|
||||||
|
_save_config(raw)
|
||||||
|
return _mask_secrets_for_read(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def get_credentials(platform: str) -> Dict[str, str]:
|
||||||
|
"""供 cloud_doc_service 使用:读取某平台明文凭证。"""
|
||||||
|
raw = _load_config()
|
||||||
|
return (raw.get(platform) or {}).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_credentials() -> Dict[str, Dict[str, str]]:
|
||||||
|
"""供推送流程使用:读取全部平台凭证(明文)。"""
|
||||||
|
raw = _load_config()
|
||||||
|
return {k: dict(v) for k, v in raw.items() if isinstance(v, dict)}
|
||||||
91
backend/app/routers/cloud_docs.py
Normal file
91
backend/app/routers/cloud_docs.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
云文档快捷入口:持久化在 data/cloud_docs.json,支持增删改查。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/cloud-docs", tags=["cloud-docs"])
|
||||||
|
|
||||||
|
CONFIG_PATH = Path("data/cloud_docs.json")
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDocLinkCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
|
||||||
|
url: str = Field(..., min_length=1, max_length=512, description="登录/入口 URL")
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDocLinkRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDocLinkUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
url: str | None = Field(None, min_length=1, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_links() -> List[Dict[str, Any]]:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_links(links: List[Dict[str, Any]]) -> None:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(
|
||||||
|
json.dumps(links, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[CloudDocLinkRead])
|
||||||
|
async def list_cloud_docs():
|
||||||
|
"""获取所有云文档快捷入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
return [CloudDocLinkRead(**x) for x in links]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CloudDocLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_cloud_doc(payload: CloudDocLinkCreate):
|
||||||
|
"""新增一条云文档入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
|
||||||
|
links.append(new_item)
|
||||||
|
_save_links(links)
|
||||||
|
return CloudDocLinkRead(**new_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{link_id}", response_model=CloudDocLinkRead)
|
||||||
|
async def update_cloud_doc(link_id: str, payload: CloudDocLinkUpdate):
|
||||||
|
"""更新名称或 URL。"""
|
||||||
|
links = _load_links()
|
||||||
|
for item in links:
|
||||||
|
if item.get("id") == link_id:
|
||||||
|
if payload.name is not None:
|
||||||
|
item["name"] = payload.name.strip()
|
||||||
|
if payload.url is not None:
|
||||||
|
item["url"] = payload.url.strip()
|
||||||
|
_save_links(links)
|
||||||
|
return CloudDocLinkRead(**item)
|
||||||
|
raise HTTPException(status_code=404, detail="云文档入口不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_cloud_doc(link_id: str):
|
||||||
|
"""删除一条云文档入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
new_list = [x for x in links if x.get("id") != link_id]
|
||||||
|
if len(new_list) == len(links):
|
||||||
|
raise HTTPException(status_code=404, detail="云文档入口不存在")
|
||||||
|
_save_links(new_list)
|
||||||
@@ -16,9 +16,18 @@ router = APIRouter(prefix="/customers", tags=["customers"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[CustomerRead])
|
@router.get("/", response_model=List[CustomerRead])
|
||||||
async def list_customers(db: Session = Depends(get_db)):
|
async def list_customers(
|
||||||
customers = db.query(models.Customer).order_by(models.Customer.created_at.desc()).all()
|
q: str | None = None,
|
||||||
return customers
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""列表客户,支持 q 按名称、联系方式模糊搜索。"""
|
||||||
|
query = db.query(models.Customer).order_by(models.Customer.created_at.desc())
|
||||||
|
if q and q.strip():
|
||||||
|
term = f"%{q.strip()}%"
|
||||||
|
query = query.filter(
|
||||||
|
(models.Customer.name.ilike(term)) | (models.Customer.contact_info.ilike(term))
|
||||||
|
)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -26,6 +35,7 @@ async def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)
|
|||||||
customer = models.Customer(
|
customer = models.Customer(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
contact_info=payload.contact_info,
|
contact_info=payload.contact_info,
|
||||||
|
tags=payload.tags,
|
||||||
)
|
)
|
||||||
db.add(customer)
|
db.add(customer)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -53,6 +63,8 @@ async def update_customer(
|
|||||||
customer.name = payload.name
|
customer.name = payload.name
|
||||||
if payload.contact_info is not None:
|
if payload.contact_info is not None:
|
||||||
customer.contact_info = payload.contact_info
|
customer.contact_info = payload.contact_info
|
||||||
|
if payload.tags is not None:
|
||||||
|
customer.tags = payload.tags
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(customer)
|
db.refresh(customer)
|
||||||
|
|||||||
183
backend/app/routers/email_configs.py
Normal file
183
backend/app/routers/email_configs.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Email accounts for multi-email finance sync. Stored in data/email_configs.json.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/email", tags=["email-configs"])
|
||||||
|
|
||||||
|
CONFIG_PATH = Path("data/email_configs.json")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfigCreate(BaseModel):
|
||||||
|
host: str = Field(..., description="IMAP host")
|
||||||
|
port: int = Field(993, description="IMAP port")
|
||||||
|
user: str = Field(..., description="Email address")
|
||||||
|
password: str = Field(..., description="Password or authorization code")
|
||||||
|
mailbox: str = Field("INBOX", description="Mailbox name")
|
||||||
|
active: bool = Field(True, description="Include in sync")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfigRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
user: str
|
||||||
|
mailbox: str
|
||||||
|
active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfigUpdate(BaseModel):
|
||||||
|
host: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
mailbox: str | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configs() -> List[Dict[str, Any]]:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_configs(configs: List[Dict[str, Any]]) -> None:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(
|
||||||
|
json.dumps(configs, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(c: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": c["id"],
|
||||||
|
"host": c["host"],
|
||||||
|
"port": c["port"],
|
||||||
|
"user": c["user"],
|
||||||
|
"mailbox": c.get("mailbox", "INBOX"),
|
||||||
|
"active": c.get("active", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[EmailConfigRead])
|
||||||
|
async def list_email_configs():
|
||||||
|
"""List all email account configs (password omitted)."""
|
||||||
|
configs = _load_configs()
|
||||||
|
return [_to_read(c) for c in configs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=EmailConfigRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_email_config(payload: EmailConfigCreate):
|
||||||
|
"""Add a new email account."""
|
||||||
|
configs = _load_configs()
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
configs.append({
|
||||||
|
"id": new_id,
|
||||||
|
"host": payload.host,
|
||||||
|
"port": payload.port,
|
||||||
|
"user": payload.user,
|
||||||
|
"password": payload.password,
|
||||||
|
"mailbox": payload.mailbox,
|
||||||
|
"active": payload.active,
|
||||||
|
})
|
||||||
|
_save_configs(configs)
|
||||||
|
return _to_read(configs[-1])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}", response_model=EmailConfigRead)
|
||||||
|
async def update_email_config(config_id: str, payload: EmailConfigUpdate):
|
||||||
|
"""Update an email account (omit password to keep existing)."""
|
||||||
|
configs = _load_configs()
|
||||||
|
for c in configs:
|
||||||
|
if c.get("id") == config_id:
|
||||||
|
if payload.host is not None:
|
||||||
|
c["host"] = payload.host
|
||||||
|
if payload.port is not None:
|
||||||
|
c["port"] = payload.port
|
||||||
|
if payload.user is not None:
|
||||||
|
c["user"] = payload.user
|
||||||
|
if payload.password is not None:
|
||||||
|
c["password"] = payload.password
|
||||||
|
if payload.mailbox is not None:
|
||||||
|
c["mailbox"] = payload.mailbox
|
||||||
|
if payload.active is not None:
|
||||||
|
c["active"] = payload.active
|
||||||
|
_save_configs(configs)
|
||||||
|
return _to_read(c)
|
||||||
|
raise HTTPException(status_code=404, detail="Email config not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_email_config(config_id: str):
|
||||||
|
"""Remove an email account."""
|
||||||
|
configs = _load_configs()
|
||||||
|
new_list = [c for c in configs if c.get("id") != config_id]
|
||||||
|
if len(new_list) == len(configs):
|
||||||
|
raise HTTPException(status_code=404, detail="Email config not found")
|
||||||
|
_save_configs(new_list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}/folders")
|
||||||
|
async def list_email_folders(config_id: str):
|
||||||
|
"""
|
||||||
|
List mailbox folders for this account (for choosing custom labels).
|
||||||
|
Returns [{ "raw": "...", "decoded": "收件箱" }, ...]. Use decoded for display and for mailbox config.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from backend.app.services.email_service import list_mailboxes_for_config
|
||||||
|
|
||||||
|
configs = _load_configs()
|
||||||
|
config = next((c for c in configs if c.get("id") == config_id), None)
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Email config not found")
|
||||||
|
host = config.get("host")
|
||||||
|
user = config.get("user")
|
||||||
|
password = config.get("password")
|
||||||
|
port = int(config.get("port", 993))
|
||||||
|
if not all([host, user, password]):
|
||||||
|
raise HTTPException(status_code=400, detail="Config missing host/user/password")
|
||||||
|
|
||||||
|
def _fetch():
|
||||||
|
return list_mailboxes_for_config(host, port, user, password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
folders = await asyncio.to_thread(_fetch)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"无法连接邮箱或获取文件夹列表: {e}") from e
|
||||||
|
|
||||||
|
return {"folders": [{"raw": r, "decoded": d} for r, d in folders]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_configs_for_sync() -> List[Dict[str, Any]]:
|
||||||
|
"""Return list of configs that are active (for sync). Falls back to env if file empty."""
|
||||||
|
configs = _load_configs()
|
||||||
|
active = [c for c in configs if c.get("active", True)]
|
||||||
|
if active:
|
||||||
|
return active
|
||||||
|
# Fallback to single account from env
|
||||||
|
import os
|
||||||
|
host = os.getenv("IMAP_HOST")
|
||||||
|
user = os.getenv("IMAP_USER")
|
||||||
|
password = os.getenv("IMAP_PASSWORD")
|
||||||
|
if host and user and password:
|
||||||
|
return [{
|
||||||
|
"id": "env",
|
||||||
|
"host": host,
|
||||||
|
"port": int(os.getenv("IMAP_PORT", "993")),
|
||||||
|
"user": user,
|
||||||
|
"password": password,
|
||||||
|
"mailbox": os.getenv("IMAP_MAILBOX", "INBOX"),
|
||||||
|
"active": True,
|
||||||
|
}]
|
||||||
|
return []
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from typing import List
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
|
|
||||||
from backend.app.schemas import FinanceSyncResponse, FinanceSyncResult
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.app.db import get_db
|
||||||
|
from backend.app import models
|
||||||
|
from backend.app.schemas import (
|
||||||
|
FinanceRecordRead,
|
||||||
|
FinanceRecordUpdate,
|
||||||
|
FinanceSyncResponse,
|
||||||
|
FinanceSyncResult,
|
||||||
|
FinanceUploadResponse,
|
||||||
|
)
|
||||||
from backend.app.services.email_service import create_monthly_zip, sync_finance_emails
|
from backend.app.services.email_service import create_monthly_zip, sync_finance_emails
|
||||||
|
from backend.app.services.invoice_upload import process_invoice_upload
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/finance", tags=["finance"])
|
router = APIRouter(prefix="/finance", tags=["finance"])
|
||||||
@@ -13,10 +25,87 @@ async def sync_finance():
|
|||||||
try:
|
try:
|
||||||
items_raw = await sync_finance_emails()
|
items_raw = await sync_finance_emails()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
items = [FinanceSyncResult(**item) for item in items_raw]
|
details = [FinanceSyncResult(**item) for item in items_raw]
|
||||||
return FinanceSyncResponse(items=items)
|
return FinanceSyncResponse(
|
||||||
|
status="success",
|
||||||
|
new_files=len(details),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/months", response_model=List[str])
|
||||||
|
async def list_finance_months(db: Session = Depends(get_db)):
|
||||||
|
"""List distinct months that have finance records (YYYY-MM), newest first."""
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(distinct(models.FinanceRecord.month))
|
||||||
|
.order_by(models.FinanceRecord.month.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/records", response_model=List[FinanceRecordRead])
|
||||||
|
async def list_finance_records(
|
||||||
|
month: str = Query(..., description="YYYY-MM"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List finance records for a given month."""
|
||||||
|
records = (
|
||||||
|
db.query(models.FinanceRecord)
|
||||||
|
.filter(models.FinanceRecord.month == month)
|
||||||
|
.order_by(models.FinanceRecord.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FinanceUploadResponse, status_code=201)
|
||||||
|
async def upload_invoice(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Upload an invoice (PDF or image). Saves to data/finance/{YYYY-MM}/manual/, runs AI OCR for amount/date."""
|
||||||
|
suf = (file.filename or "").lower().split(".")[-1] if "." in (file.filename or "") else ""
|
||||||
|
allowed = {"pdf", "jpg", "jpeg", "png", "webp"}
|
||||||
|
if suf not in allowed:
|
||||||
|
raise HTTPException(400, "仅支持 PDF、JPG、PNG、WEBP 格式")
|
||||||
|
file_name, file_path, month_str, amount, billing_date = await process_invoice_upload(file)
|
||||||
|
record = models.FinanceRecord(
|
||||||
|
month=month_str,
|
||||||
|
type="manual",
|
||||||
|
file_name=file_name,
|
||||||
|
file_path=file_path,
|
||||||
|
amount=amount,
|
||||||
|
billing_date=billing_date,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/records/{record_id}", response_model=FinanceRecordRead)
|
||||||
|
async def update_finance_record(
|
||||||
|
record_id: int,
|
||||||
|
payload: FinanceRecordUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update amount and/or billing_date of a finance record (e.g. after manual review)."""
|
||||||
|
record = db.query(models.FinanceRecord).get(record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(404, "记录不存在")
|
||||||
|
if payload.amount is not None:
|
||||||
|
record.amount = payload.amount
|
||||||
|
if payload.billing_date is not None:
|
||||||
|
record.billing_date = payload.billing_date
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download/{month}")
|
@router.get("/download/{month}")
|
||||||
|
|||||||
91
backend/app/routers/portal_links.py
Normal file
91
backend/app/routers/portal_links.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
快捷门户入口:持久化在 data/portal_links.json,支持增删改查。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings/portal-links", tags=["portal-links"])
|
||||||
|
|
||||||
|
CONFIG_PATH = Path("data/portal_links.json")
|
||||||
|
|
||||||
|
|
||||||
|
class PortalLinkCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
|
||||||
|
url: str = Field(..., min_length=1, max_length=512, description="门户 URL")
|
||||||
|
|
||||||
|
|
||||||
|
class PortalLinkRead(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class PortalLinkUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
url: str | None = Field(None, min_length=1, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_links() -> List[Dict[str, Any]]:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_links(links: List[Dict[str, Any]]) -> None:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(
|
||||||
|
json.dumps(links, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[PortalLinkRead])
|
||||||
|
async def list_portal_links():
|
||||||
|
"""获取所有快捷门户入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
return [PortalLinkRead(**x) for x in links]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PortalLinkRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_portal_link(payload: PortalLinkCreate):
|
||||||
|
"""新增一条快捷门户入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
new_id = str(uuid.uuid4())[:8]
|
||||||
|
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
|
||||||
|
links.append(new_item)
|
||||||
|
_save_links(links)
|
||||||
|
return PortalLinkRead(**new_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{link_id}", response_model=PortalLinkRead)
|
||||||
|
async def update_portal_link(link_id: str, payload: PortalLinkUpdate):
|
||||||
|
"""更新名称或 URL。"""
|
||||||
|
links = _load_links()
|
||||||
|
for item in links:
|
||||||
|
if item.get("id") == link_id:
|
||||||
|
if payload.name is not None:
|
||||||
|
item["name"] = payload.name.strip()
|
||||||
|
if payload.url is not None:
|
||||||
|
item["url"] = payload.url.strip()
|
||||||
|
_save_links(links)
|
||||||
|
return PortalLinkRead(**item)
|
||||||
|
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_portal_link(link_id: str):
|
||||||
|
"""删除一条快捷门户入口。"""
|
||||||
|
links = _load_links()
|
||||||
|
new_list = [x for x in links if x.get("id") != link_id]
|
||||||
|
if len(new_list) == len(links):
|
||||||
|
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
|
||||||
|
_save_links(new_list)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from backend.app import models
|
from backend.app import models
|
||||||
from backend.app.db import get_db
|
from backend.app.db import get_db
|
||||||
@@ -11,25 +13,35 @@ from backend.app.schemas import (
|
|||||||
ContractGenerateResponse,
|
ContractGenerateResponse,
|
||||||
ProjectRead,
|
ProjectRead,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
|
PushToCloudRequest,
|
||||||
|
PushToCloudResponse,
|
||||||
QuoteGenerateResponse,
|
QuoteGenerateResponse,
|
||||||
RequirementAnalyzeRequest,
|
RequirementAnalyzeRequest,
|
||||||
RequirementAnalyzeResponse,
|
RequirementAnalyzeResponse,
|
||||||
)
|
)
|
||||||
from backend.app.services.ai_service import analyze_requirement
|
from backend.app.services.ai_service import analyze_requirement
|
||||||
|
from backend.app.services.cloud_doc_service import CloudDocManager
|
||||||
from backend.app.services.doc_service import (
|
from backend.app.services.doc_service import (
|
||||||
generate_contract_word,
|
generate_contract_word,
|
||||||
generate_quote_excel,
|
generate_quote_excel,
|
||||||
generate_quote_pdf_from_data,
|
generate_quote_pdf_from_data,
|
||||||
)
|
)
|
||||||
|
from backend.app.routers.cloud_doc_config import get_all_credentials
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str:
|
||||||
"""
|
"""
|
||||||
Convert structured AI analysis JSON into a human-editable Markdown document.
|
Convert structured AI analysis JSON into a human-editable Markdown document.
|
||||||
|
Tolerates AI returning a list (e.g. modules only) and normalizes to a dict.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
lines.append("# 项目方案草稿")
|
lines.append("# 项目方案草稿")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -48,7 +60,15 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
|||||||
if modules:
|
if modules:
|
||||||
lines.append("## 功能模块与技术实现")
|
lines.append("## 功能模块与技术实现")
|
||||||
for idx, module in enumerate(modules, start=1):
|
for idx, module in enumerate(modules, start=1):
|
||||||
name = module.get("name", f"模块 {idx}")
|
if not isinstance(module, dict):
|
||||||
|
# AI sometimes returns strings or other shapes; treat as a single title line
|
||||||
|
raw_name = str(module).strip() if module else ""
|
||||||
|
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
|
||||||
|
lines.append(f"### {idx}. {name}")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
raw_name = (module.get("name") or "").strip()
|
||||||
|
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
|
||||||
desc = module.get("description") or ""
|
desc = module.get("description") or ""
|
||||||
tech = module.get("technical_approach") or ""
|
tech = module.get("technical_approach") or ""
|
||||||
hours = module.get("estimated_hours")
|
hours = module.get("estimated_hours")
|
||||||
@@ -83,13 +103,31 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[ProjectRead])
|
@router.get("/", response_model=list[ProjectRead])
|
||||||
async def list_projects(db: Session = Depends(get_db)):
|
async def list_projects(
|
||||||
projects = (
|
customer_tag: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
||||||
|
query = (
|
||||||
db.query(models.Project)
|
db.query(models.Project)
|
||||||
|
.options(joinedload(models.Project.customer))
|
||||||
|
.join(models.Customer)
|
||||||
.order_by(models.Project.created_at.desc())
|
.order_by(models.Project.created_at.desc())
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
return projects
|
if customer_tag and customer_tag.strip():
|
||||||
|
tag = customer_tag.strip()
|
||||||
|
# 客户 tags 逗号分隔,按整词匹配
|
||||||
|
from sqlalchemy import or_
|
||||||
|
t = models.Customer.tags
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
t == tag,
|
||||||
|
t.ilike(f"{tag},%"),
|
||||||
|
t.ilike(f"%,{tag},%"),
|
||||||
|
t.ilike(f"%,{tag}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectRead)
|
@router.get("/{project_id}", response_model=ProjectRead)
|
||||||
@@ -109,6 +147,8 @@ async def update_project(
|
|||||||
project = db.query(models.Project).get(project_id)
|
project = db.query(models.Project).get(project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
if payload.raw_requirement is not None:
|
||||||
|
project.raw_requirement = payload.raw_requirement
|
||||||
if payload.ai_solution_md is not None:
|
if payload.ai_solution_md is not None:
|
||||||
project.ai_solution_md = payload.ai_solution_md
|
project.ai_solution_md = payload.ai_solution_md
|
||||||
if payload.status is not None:
|
if payload.status is not None:
|
||||||
@@ -123,12 +163,24 @@ async def analyze_project_requirement(
|
|||||||
payload: RequirementAnalyzeRequest,
|
payload: RequirementAnalyzeRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"收到 AI 解析请求: customer_id=%s, 需求长度=%d",
|
||||||
|
payload.customer_id,
|
||||||
|
len(payload.raw_text or ""),
|
||||||
|
)
|
||||||
# Ensure customer exists
|
# Ensure customer exists
|
||||||
customer = db.query(models.Customer).get(payload.customer_id)
|
customer = db.query(models.Customer).get(payload.customer_id)
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
||||||
|
|
||||||
|
try:
|
||||||
analysis = await analyze_requirement(payload.raw_text)
|
analysis = await analyze_requirement(payload.raw_text)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
|
||||||
ai_solution_md = _build_markdown_from_analysis(analysis)
|
ai_solution_md = _build_markdown_from_analysis(analysis)
|
||||||
|
|
||||||
project = models.Project(
|
project = models.Project(
|
||||||
@@ -151,6 +203,7 @@ async def analyze_project_requirement(
|
|||||||
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
||||||
async def generate_project_quote(
|
async def generate_project_quote(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
template: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
project = db.query(models.Project).get(project_id)
|
project = db.query(models.Project).get(project_id)
|
||||||
@@ -167,7 +220,9 @@ async def generate_project_quote(
|
|||||||
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
|
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
|
||||||
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
|
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
|
||||||
|
|
||||||
template_path = Path("templates/quote_template.xlsx")
|
from backend.app.routers.settings import get_quote_template_path
|
||||||
|
|
||||||
|
template_path = get_quote_template_path(template)
|
||||||
if not template_path.exists():
|
if not template_path.exists():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@@ -254,3 +309,61 @@ async def generate_project_contract(
|
|||||||
|
|
||||||
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))
|
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse)
|
||||||
|
async def push_project_to_cloud(
|
||||||
|
project_id: int,
|
||||||
|
payload: PushToCloudRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
将当前项目方案(Markdown)推送到云文档。若该项目此前已推送过该平台,则更新原文档(增量同步)。
|
||||||
|
"""
|
||||||
|
project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
platform = (payload.platform or "").strip().lower()
|
||||||
|
if platform not in ("feishu", "yuque", "tencent"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="platform 须为 feishu / yuque / tencent",
|
||||||
|
)
|
||||||
|
title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}"
|
||||||
|
body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or ""
|
||||||
|
if not body_md.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送",
|
||||||
|
)
|
||||||
|
existing = (
|
||||||
|
db.query(models.ProjectCloudDoc)
|
||||||
|
.filter(
|
||||||
|
models.ProjectCloudDoc.project_id == project_id,
|
||||||
|
models.ProjectCloudDoc.platform == platform,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
existing_doc_id = existing.cloud_doc_id if existing else None
|
||||||
|
credentials = get_all_credentials()
|
||||||
|
manager = CloudDocManager(credentials)
|
||||||
|
try:
|
||||||
|
cloud_doc_id, url = await manager.push_markdown(
|
||||||
|
platform, title, body_md, existing_doc_id=existing_doc_id
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
if existing:
|
||||||
|
existing.cloud_doc_id = cloud_doc_id
|
||||||
|
existing.cloud_url = url
|
||||||
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
record = models.ProjectCloudDoc(
|
||||||
|
project_id=project_id,
|
||||||
|
platform=platform,
|
||||||
|
cloud_doc_id=cloud_doc_id,
|
||||||
|
cloud_url=url,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id)
|
||||||
|
|
||||||
|
|||||||
93
backend/app/routers/settings.py
Normal file
93
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path("data/templates")
|
||||||
|
ALLOWED_EXCEL = {".xlsx", ".xltx"}
|
||||||
|
ALLOWED_WORD = {".docx", ".dotx"}
|
||||||
|
ALLOWED_EXTENSIONS = ALLOWED_EXCEL | ALLOWED_WORD
|
||||||
|
|
||||||
|
# Allowed MIME types when client sends Content-Type (validate if present)
|
||||||
|
ALLOWED_MIME_TYPES = frozenset({
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.template", # .xltx
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", # .dotx
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_templates_dir() -> Path:
|
||||||
|
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return TEMPLATES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=List[dict])
|
||||||
|
async def list_templates():
|
||||||
|
"""List uploaded template files (name, type, size, mtime)."""
|
||||||
|
_ensure_templates_dir()
|
||||||
|
out: List[dict] = []
|
||||||
|
for f in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
suf = f.suffix.lower()
|
||||||
|
if suf not in ALLOWED_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
st = f.stat()
|
||||||
|
out.append({
|
||||||
|
"name": f.name,
|
||||||
|
"type": "excel" if suf in ALLOWED_EXCEL else "word",
|
||||||
|
"size": st.st_size,
|
||||||
|
"uploaded_at": st.st_mtime,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates/upload", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_template(file: UploadFile = File(...)):
|
||||||
|
"""Upload a .xlsx, .xltx, .docx or .dotx template to data/templates/."""
|
||||||
|
suf = Path(file.filename or "").suffix.lower()
|
||||||
|
if suf not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Only .xlsx, .xltx, .docx and .dotx files are allowed.",
|
||||||
|
)
|
||||||
|
content_type = (file.content_type or "").strip().split(";")[0].strip().lower()
|
||||||
|
if content_type and content_type not in ALLOWED_MIME_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid content type. Allowed: .xlsx, .xltx, .docx, .dotx Office formats.",
|
||||||
|
)
|
||||||
|
dir_path = _ensure_templates_dir()
|
||||||
|
dest = dir_path / (file.filename or "template" + suf)
|
||||||
|
content = await file.read()
|
||||||
|
dest.write_bytes(content)
|
||||||
|
return {"name": dest.name, "path": str(dest)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_excel_template() -> Path | None:
|
||||||
|
"""Return path to the most recently modified .xlsx or .xltx in data/templates, or None."""
|
||||||
|
if not TEMPLATES_DIR.exists():
|
||||||
|
return None
|
||||||
|
excel_files = [
|
||||||
|
f for f in TEMPLATES_DIR.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in ALLOWED_EXCEL
|
||||||
|
]
|
||||||
|
if not excel_files:
|
||||||
|
return None
|
||||||
|
return max(excel_files, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_template_path(template_filename: str | None) -> Path:
|
||||||
|
"""Resolve quote template path: optional filename in data/templates or latest excel template or default."""
|
||||||
|
if template_filename:
|
||||||
|
candidate = TEMPLATES_DIR / template_filename
|
||||||
|
if candidate.is_file() and candidate.suffix.lower() in ALLOWED_EXCEL:
|
||||||
|
return candidate
|
||||||
|
latest = get_latest_excel_template()
|
||||||
|
if latest:
|
||||||
|
return latest
|
||||||
|
default = Path("templates/quote_template.xlsx")
|
||||||
|
return default
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
|||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
name: str = Field(..., description="Customer name")
|
name: str = Field(..., description="Customer name")
|
||||||
contact_info: Optional[str] = Field(None, description="Contact information")
|
contact_info: Optional[str] = Field(None, description="Contact information")
|
||||||
|
tags: Optional[str] = Field(None, description="Comma-separated tags, e.g. 重点客户,已签约")
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(CustomerBase):
|
class CustomerCreate(CustomerBase):
|
||||||
@@ -16,6 +17,7 @@ class CustomerCreate(CustomerBase):
|
|||||||
class CustomerUpdate(BaseModel):
|
class CustomerUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
contact_info: Optional[str] = None
|
contact_info: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomerRead(CustomerBase):
|
class CustomerRead(CustomerBase):
|
||||||
@@ -33,12 +35,14 @@ class ProjectRead(BaseModel):
|
|||||||
ai_solution_md: Optional[str] = None
|
ai_solution_md: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
customer: Optional[CustomerRead] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
|
raw_requirement: Optional[str] = None
|
||||||
ai_solution_md: Optional[str] = None
|
ai_solution_md: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
|
|
||||||
@@ -75,6 +79,17 @@ class ContractGenerateResponse(BaseModel):
|
|||||||
contract_path: str
|
contract_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushToCloudRequest(BaseModel):
|
||||||
|
platform: str = Field(..., description="feishu | yuque | tencent")
|
||||||
|
title: Optional[str] = Field(None, description="文档标题,默认使用「项目方案 - 项目#id」")
|
||||||
|
body_md: Optional[str] = Field(None, description="要推送的 Markdown 内容,不传则使用项目已保存的方案")
|
||||||
|
|
||||||
|
|
||||||
|
class PushToCloudResponse(BaseModel):
|
||||||
|
url: str
|
||||||
|
cloud_doc_id: str
|
||||||
|
|
||||||
|
|
||||||
class FinanceSyncResult(BaseModel):
|
class FinanceSyncResult(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
month: str
|
month: str
|
||||||
@@ -84,5 +99,40 @@ class FinanceSyncResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FinanceSyncResponse(BaseModel):
|
class FinanceSyncResponse(BaseModel):
|
||||||
items: List[FinanceSyncResult]
|
status: str = "success"
|
||||||
|
new_files: int = 0
|
||||||
|
details: List[FinanceSyncResult] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceRecordRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
month: str
|
||||||
|
type: str
|
||||||
|
file_name: str
|
||||||
|
file_path: str
|
||||||
|
amount: Optional[float] = None
|
||||||
|
billing_date: Optional[date] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceRecordUpdate(BaseModel):
|
||||||
|
amount: Optional[float] = None
|
||||||
|
billing_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceUploadResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
month: str
|
||||||
|
type: str
|
||||||
|
file_name: str
|
||||||
|
file_path: str
|
||||||
|
amount: Optional[float] = None
|
||||||
|
billing_date: Optional[date] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,71 @@
|
|||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
from openai import NotFoundError as OpenAINotFoundError
|
||||||
|
|
||||||
|
AI_CONFIG_PATH = Path("data/ai_config.json")
|
||||||
|
AI_CONFIGS_PATH = Path("data/ai_configs.json")
|
||||||
|
|
||||||
|
|
||||||
_client: AsyncOpenAI | None = None
|
def get_active_ai_config() -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def get_ai_client() -> AsyncOpenAI:
|
|
||||||
"""
|
"""
|
||||||
Create (or reuse) a singleton AsyncOpenAI client.
|
从 data/ai_configs.json 读取当前选用的配置;若无则从旧版 ai_config.json 迁移并返回。
|
||||||
|
供 router 与内部调用。
|
||||||
The client is configured via:
|
|
||||||
- AI_API_KEY / OPENAI_API_KEY
|
|
||||||
- AI_BASE_URL (optional, defaults to official OpenAI endpoint)
|
|
||||||
- AI_MODEL (optional, defaults to gpt-4.1-mini or a similar capable model)
|
|
||||||
"""
|
"""
|
||||||
global _client
|
defaults = {
|
||||||
if _client is not None:
|
"id": "",
|
||||||
return _client
|
"name": "",
|
||||||
|
"provider": "OpenAI",
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"model_name": "gpt-4o-mini",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"system_prompt_override": "",
|
||||||
|
}
|
||||||
|
if AI_CONFIGS_PATH.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(AI_CONFIGS_PATH.read_text(encoding="utf-8"))
|
||||||
|
configs = data.get("configs") or []
|
||||||
|
active_id = data.get("active_id") or ""
|
||||||
|
for c in configs:
|
||||||
|
if c.get("id") == active_id:
|
||||||
|
return {**defaults, **c}
|
||||||
|
if configs:
|
||||||
|
return {**defaults, **configs[0]}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 兼容旧版单文件
|
||||||
|
if AI_CONFIG_PATH.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(AI_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
return {**defaults, **data}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not defaults.get("api_key"):
|
||||||
|
defaults["api_key"] = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
|
||||||
|
if not defaults.get("base_url") and os.getenv("AI_BASE_URL"):
|
||||||
|
defaults["base_url"] = os.getenv("AI_BASE_URL")
|
||||||
|
if defaults.get("model_name") == "gpt-4o-mini" and os.getenv("AI_MODEL"):
|
||||||
|
defaults["model_name"] = os.getenv("AI_MODEL")
|
||||||
|
return defaults
|
||||||
|
|
||||||
api_key = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
||||||
|
def _load_ai_config() -> Dict[str, Any]:
|
||||||
|
"""当前生效的 AI 配置(供需求解析、发票识别等使用)。"""
|
||||||
|
return get_active_ai_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _client_from_config(config: Dict[str, Any]) -> AsyncOpenAI:
|
||||||
|
api_key = (config.get("api_key") or "").strip()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("AI_API_KEY or OPENAI_API_KEY must be set in environment.")
|
raise RuntimeError("AI API Key 未配置,请在 设置 → AI 模型配置 中填写。")
|
||||||
|
base_url = (config.get("base_url") or "").strip() or None
|
||||||
base_url = os.getenv("AI_BASE_URL") # can point to OpenAI, DeepSeek, Qwen, etc.
|
return AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
|
||||||
_client = AsyncOpenAI(
|
|
||||||
api_key=api_key,
|
|
||||||
base_url=base_url or None,
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
def _build_requirement_prompt(raw_text: str) -> str:
|
def _build_requirement_prompt(raw_text: str) -> str:
|
||||||
@@ -71,38 +105,139 @@ def _build_requirement_prompt(raw_text: str) -> str:
|
|||||||
async def analyze_requirement(raw_text: str) -> Dict[str, Any]:
|
async def analyze_requirement(raw_text: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Call the AI model to analyze customer requirements.
|
Call the AI model to analyze customer requirements.
|
||||||
|
Reads config from data/ai_config.json (and env fallback) on every request.
|
||||||
Returns a Python dict matching the JSON structure described
|
|
||||||
in `_build_requirement_prompt`.
|
|
||||||
"""
|
"""
|
||||||
client = get_ai_client()
|
import logging
|
||||||
model = os.getenv("AI_MODEL", "gpt-4.1-mini")
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
config = _load_ai_config()
|
||||||
|
client = _client_from_config(config)
|
||||||
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
temperature = float(config.get("temperature", 0.2))
|
||||||
|
system_override = (config.get("system_prompt_override") or "").strip()
|
||||||
|
|
||||||
|
logger.info("AI 需求解析: 调用模型 %s,输入长度 %d 字符", model, len(raw_text))
|
||||||
|
|
||||||
prompt = _build_requirement_prompt(raw_text)
|
prompt = _build_requirement_prompt(raw_text)
|
||||||
|
system_content = (
|
||||||
|
system_override
|
||||||
|
if system_override
|
||||||
|
else "你是一名严谨的系统架构师,只能输出有效的 JSON,不要输出任何解释文字。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
completion = await client.chat.completions.create(
|
completion = await client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{"role": "system", "content": system_content},
|
||||||
"role": "system",
|
{"role": "user", "content": prompt},
|
||||||
"content": (
|
|
||||||
"你是一名严谨的系统架构师,只能输出有效的 JSON,不要输出任何解释文字。"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": prompt,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
temperature=0.2,
|
temperature=temperature,
|
||||||
)
|
)
|
||||||
|
except OpenAINotFoundError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」与当前提供商一致(如阿里云使用 qwen 系列、OpenAI 使用 gpt-4o-mini 等)。"
|
||||||
|
) from e
|
||||||
|
|
||||||
content = completion.choices[0].message.content or "{}"
|
content = completion.choices[0].message.content or "{}"
|
||||||
try:
|
try:
|
||||||
data: Dict[str, Any] = json.loads(content)
|
data: Any = json.loads(content)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error("AI 返回非 JSON,片段: %s", (content or "")[:200])
|
||||||
raise RuntimeError(f"AI 返回的内容不是合法 JSON:{content}") from exc
|
raise RuntimeError(f"AI 返回的内容不是合法 JSON:{content}") from exc
|
||||||
|
|
||||||
|
# Some models return a list (e.g. modules only); normalize to expected dict shape
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = {
|
||||||
|
"modules": data,
|
||||||
|
"total_estimated_hours": None,
|
||||||
|
"total_amount": None,
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
mods = data.get("modules") or []
|
||||||
|
logger.info("AI 需求解析完成: 模块数 %d", len(mods) if isinstance(mods, list) else 0)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection() -> str:
|
||||||
|
"""使用当前选用配置测试连接。"""
|
||||||
|
return await test_connection_with_config(get_active_ai_config())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection_with_config(config: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
使用指定配置发送简单补全以验证 API Key 与 Base URL。
|
||||||
|
供测试当前配置或指定 config_id 时使用。
|
||||||
|
"""
|
||||||
|
client = _client_from_config(config)
|
||||||
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
try:
|
||||||
|
completion = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": "Hello"}],
|
||||||
|
max_tokens=50,
|
||||||
|
)
|
||||||
|
except OpenAINotFoundError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」(如阿里云使用 qwen 系列)。"
|
||||||
|
) from e
|
||||||
|
return (completion.choices[0].message.content or "").strip() or "OK"
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_invoice_metadata(image_bytes: bytes, mime: str = "image/jpeg") -> Tuple[float | None, str | None]:
|
||||||
|
"""
|
||||||
|
Use AI vision to extract total amount and invoice date from an image.
|
||||||
|
Returns (amount, date_yyyy_mm_dd). On any error or unsupported model, returns (None, None).
|
||||||
|
"""
|
||||||
|
config = _load_ai_config()
|
||||||
|
api_key = (config.get("api_key") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
return (None, None)
|
||||||
|
try:
|
||||||
|
client = _client_from_config(config)
|
||||||
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||||
|
data_url = f"data:{mime};base64,{b64}"
|
||||||
|
prompt = (
|
||||||
|
"从这张发票/收据图片中识别并提取:1) 价税合计/总金额(数字,不含货币符号);2) 开票日期(格式 YYYY-MM-DD)。"
|
||||||
|
"只返回 JSON,不要其他文字,格式:{\"amount\": 数字或null, \"date\": \"YYYY-MM-DD\" 或 null}。"
|
||||||
|
)
|
||||||
|
completion = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": data_url}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=150,
|
||||||
|
)
|
||||||
|
content = (completion.choices[0].message.content or "").strip()
|
||||||
|
if not content:
|
||||||
|
return (None, None)
|
||||||
|
# Handle markdown code block
|
||||||
|
if "```" in content:
|
||||||
|
content = re.sub(r"^.*?```(?:json)?\s*", "", content).strip()
|
||||||
|
content = re.sub(r"\s*```.*$", "", content).strip()
|
||||||
|
data = json.loads(content)
|
||||||
|
amount_raw = data.get("amount")
|
||||||
|
date_raw = data.get("date")
|
||||||
|
amount = None
|
||||||
|
if amount_raw is not None:
|
||||||
|
try:
|
||||||
|
amount = float(amount_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
date_str = None
|
||||||
|
if isinstance(date_raw, str) and re.match(r"\d{4}-\d{2}-\d{2}", date_raw):
|
||||||
|
date_str = date_raw[:10]
|
||||||
|
return (amount, date_str)
|
||||||
|
except Exception:
|
||||||
|
return (None, None)
|
||||||
|
|||||||
315
backend/app/services/cloud_doc_service.py
Normal file
315
backend/app/services/cloud_doc_service.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
云文档集成:飞书、语雀、腾讯文档的文档创建/更新。
|
||||||
|
统一以 Markdown 为中间格式,由各平台 API 写入。
|
||||||
|
|
||||||
|
扩展建议:可增加「月度财务明细表」自动导出——每月在飞书/腾讯文档生成表格,
|
||||||
|
插入当月发票等附件预览链接,供财务查看(需对接财务记录与附件列表)。
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
FEISHU_BASE = "https://open.feishu.cn"
|
||||||
|
YUQUE_BASE = "https://www.yuque.com/api/v2"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_feishu_tenant_token(app_id: str, app_secret: str) -> str:
|
||||||
|
"""获取飞书 tenant_access_token。"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{FEISHU_BASE}/open-apis/auth/v3/tenant_access_token/internal",
|
||||||
|
json={"app_id": app_id, "app_secret": app_secret},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise RuntimeError(data.get("msg", "飞书鉴权失败"))
|
||||||
|
return data["tenant_access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _feishu_text_block_elements(md: str) -> List[Dict[str, Any]]:
|
||||||
|
"""将 Markdown 转为飞书文本块 elements(按行拆成 textRun,简单实现)。"""
|
||||||
|
elements: List[Dict[str, Any]] = []
|
||||||
|
for line in md.split("\n"):
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line:
|
||||||
|
elements.append({"type": "textRun", "text_run": {"text": "\n"}})
|
||||||
|
else:
|
||||||
|
elements.append({"type": "textRun", "text_run": {"text": line + "\n"}})
|
||||||
|
if not elements:
|
||||||
|
elements.append({"type": "textRun", "text_run": {"text": " "}})
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
async def feishu_create_doc(
|
||||||
|
token: str, title: str, body_md: str, folder_token: str = ""
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
创建飞书文档并写入内容。返回 (document_id, url)。
|
||||||
|
使用 docx/v1:创建文档后向根块下添加子块写入 Markdown 文本。
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
# 1. 创建文档
|
||||||
|
create_body: Dict[str, Any] = {"title": title[:50] or "未命名文档"}
|
||||||
|
if folder_token:
|
||||||
|
create_body["folder_token"] = folder_token
|
||||||
|
r = await client.post(
|
||||||
|
f"{FEISHU_BASE}/open-apis/docx/v1/documents",
|
||||||
|
headers=headers,
|
||||||
|
json=create_body,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise RuntimeError(data.get("msg", "飞书创建文档失败"))
|
||||||
|
doc = data.get("data", {})
|
||||||
|
document_id = doc.get("document", {}).get("document_id")
|
||||||
|
if not document_id:
|
||||||
|
raise RuntimeError("飞书未返回 document_id")
|
||||||
|
url = doc.get("document", {}).get("url", "")
|
||||||
|
# 2. 根块 ID 即 document_id(飞书约定)
|
||||||
|
block_id = document_id
|
||||||
|
# 3. 添加子块(内容)
|
||||||
|
elements = _feishu_text_block_elements(body_md)
|
||||||
|
# 单块有长度限制,分批写入多块
|
||||||
|
chunk_size = 3000
|
||||||
|
for i in range(0, len(elements), chunk_size):
|
||||||
|
chunk = elements[i : i + chunk_size]
|
||||||
|
body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1}
|
||||||
|
r3 = await client.post(
|
||||||
|
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children",
|
||||||
|
headers=headers,
|
||||||
|
json=body_json,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r3.raise_for_status()
|
||||||
|
res = r3.json()
|
||||||
|
if res.get("code") != 0:
|
||||||
|
raise RuntimeError(res.get("msg", "飞书写入块失败"))
|
||||||
|
# 下一批挂在刚创建的块下
|
||||||
|
new_items = res.get("data", {}).get("children", [])
|
||||||
|
if new_items:
|
||||||
|
block_id = new_items[0].get("block_id", block_id)
|
||||||
|
return document_id, url or f"https://feishu.cn/docx/{document_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def feishu_update_doc(token: str, document_id: str, body_md: str) -> str:
|
||||||
|
"""
|
||||||
|
更新飞书文档内容:获取现有块并批量更新首个文本块,或追加新块。
|
||||||
|
返回文档 URL。
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
r = await client.get(
|
||||||
|
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks",
|
||||||
|
headers=headers,
|
||||||
|
params={"document_id": document_id},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise RuntimeError(data.get("msg", "飞书获取块失败"))
|
||||||
|
items = data.get("data", {}).get("items", [])
|
||||||
|
elements = _feishu_text_block_elements(body_md)
|
||||||
|
if items:
|
||||||
|
first_id = items[0].get("block_id")
|
||||||
|
if first_id:
|
||||||
|
# 批量更新:只更新第一个块的内容
|
||||||
|
update_body = {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"request_type": "blockUpdate",
|
||||||
|
"block_id": first_id,
|
||||||
|
"update_text": {"elements": elements},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
r2 = await client.patch(
|
||||||
|
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/batch_update",
|
||||||
|
headers=headers,
|
||||||
|
json=update_body,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r2.raise_for_status()
|
||||||
|
if r2.json().get("code") != 0:
|
||||||
|
# 若 PATCH 不支持该块类型,则追加新块
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return f"https://feishu.cn/docx/{document_id}"
|
||||||
|
# 无块或更新失败:在根下追加子块
|
||||||
|
block_id = document_id
|
||||||
|
for i in range(0, len(elements), 3000):
|
||||||
|
chunk = elements[i : i + 3000]
|
||||||
|
body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1}
|
||||||
|
r3 = await client.post(
|
||||||
|
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children",
|
||||||
|
headers=headers,
|
||||||
|
json=body_json,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r3.raise_for_status()
|
||||||
|
res = r3.json()
|
||||||
|
if res.get("data", {}).get("children"):
|
||||||
|
block_id = res["data"]["children"][0].get("block_id", block_id)
|
||||||
|
return f"https://feishu.cn/docx/{document_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------- 语雀 ---------------
|
||||||
|
|
||||||
|
|
||||||
|
async def yuque_create_doc(
|
||||||
|
token: str, repo_id_or_namespace: str, title: str, body_md: str
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
在语雀知识库创建文档。repo_id_or_namespace 可为 repo_id 或 namespace(如 user/repo)。
|
||||||
|
返回 (doc_id, url)。
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "OpsCore-CloudDoc/1.0",
|
||||||
|
}
|
||||||
|
# 若为 namespace 需先解析为 repo_id(语雀 API 创建文档用 repo_id)
|
||||||
|
repo_id = repo_id_or_namespace
|
||||||
|
if "/" in repo_id_or_namespace:
|
||||||
|
r_repo = await client.get(
|
||||||
|
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if r_repo.status_code == 200 and r_repo.json().get("data"):
|
||||||
|
repo_id = str(r_repo.json()["data"]["id"])
|
||||||
|
r = await client.post(
|
||||||
|
f"{YUQUE_BASE}/repos/{repo_id}/docs",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"title": title[:100] or "未命名",
|
||||||
|
"body": body_md,
|
||||||
|
"format": "markdown",
|
||||||
|
},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
doc = data.get("data", {})
|
||||||
|
doc_id = str(doc.get("id", ""))
|
||||||
|
url = doc.get("url", "")
|
||||||
|
if not url and doc.get("slug"):
|
||||||
|
url = f"https://www.yuque.com/{doc.get('namespace', '').replace('/', '/')}/{doc.get('slug', '')}"
|
||||||
|
return doc_id, url or ""
|
||||||
|
|
||||||
|
|
||||||
|
async def yuque_update_doc(
|
||||||
|
token: str, repo_id_or_namespace: str, doc_id: str, title: str, body_md: str
|
||||||
|
) -> str:
|
||||||
|
"""更新语雀文档。返回文档 URL。"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "OpsCore-CloudDoc/1.0",
|
||||||
|
}
|
||||||
|
r = await client.put(
|
||||||
|
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs/{doc_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"title": title[:100] or "未命名",
|
||||||
|
"body": body_md,
|
||||||
|
"format": "markdown",
|
||||||
|
},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
doc = data.get("data", {})
|
||||||
|
return doc.get("url", "") or f"https://www.yuque.com/docs/{doc_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def yuque_list_docs(token: str, repo_id_or_namespace: str) -> List[Dict[str, Any]]:
|
||||||
|
"""获取知识库文档列表。"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
"User-Agent": "OpsCore-CloudDoc/1.0",
|
||||||
|
}
|
||||||
|
r = await client.get(
|
||||||
|
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs",
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------- 腾讯文档(占位) ---------------
|
||||||
|
|
||||||
|
|
||||||
|
async def tencent_create_doc(client_id: str, client_secret: str, title: str, body_md: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
腾讯文档需 OAuth 用户授权与文件创建 API,此处返回占位。
|
||||||
|
正式接入需在腾讯开放平台创建应用并走 OAuth 流程。
|
||||||
|
"""
|
||||||
|
raise RuntimeError(
|
||||||
|
"腾讯文档 Open API 需在开放平台配置 OAuth 并获取用户授权;当前版本请先用飞书或语雀推送。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------- 统一入口 ---------------
|
||||||
|
|
||||||
|
|
||||||
|
class CloudDocManager:
|
||||||
|
"""统一封装:读取配置并执行创建/更新,支持增量(有 cloud_doc_id 则更新)。"""
|
||||||
|
|
||||||
|
def __init__(self, credentials: Dict[str, Dict[str, str]]):
|
||||||
|
self.credentials = credentials
|
||||||
|
|
||||||
|
async def push_markdown(
|
||||||
|
self,
|
||||||
|
platform: str,
|
||||||
|
title: str,
|
||||||
|
body_md: str,
|
||||||
|
existing_doc_id: str | None = None,
|
||||||
|
extra: Dict[str, str] | None = None,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
将 Markdown 推送到指定平台。若 existing_doc_id 存在则更新,否则创建。
|
||||||
|
返回 (cloud_doc_id, url)。
|
||||||
|
extra: 平台相关参数,如 yuque 的 default_repo。
|
||||||
|
"""
|
||||||
|
extra = extra or {}
|
||||||
|
if platform == "feishu":
|
||||||
|
cred = self.credentials.get("feishu") or {}
|
||||||
|
app_id = (cred.get("app_id") or "").strip()
|
||||||
|
app_secret = (cred.get("app_secret") or "").strip()
|
||||||
|
if not app_id or not app_secret:
|
||||||
|
raise RuntimeError("请先在设置中配置飞书 App ID 与 App Secret")
|
||||||
|
token = await get_feishu_tenant_token(app_id, app_secret)
|
||||||
|
if existing_doc_id:
|
||||||
|
url = await feishu_update_doc(token, existing_doc_id, body_md)
|
||||||
|
return existing_doc_id, url
|
||||||
|
return await feishu_create_doc(token, title, body_md)
|
||||||
|
|
||||||
|
if platform == "yuque":
|
||||||
|
cred = self.credentials.get("yuque") or {}
|
||||||
|
token = (cred.get("token") or "").strip()
|
||||||
|
default_repo = (cred.get("default_repo") or extra.get("repo") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("请先在设置中配置语雀 Personal Access Token")
|
||||||
|
if not default_repo:
|
||||||
|
raise RuntimeError("请先在设置中配置语雀默认知识库(namespace,如 user/repo)")
|
||||||
|
if existing_doc_id:
|
||||||
|
url = await yuque_update_doc(token, default_repo, existing_doc_id, title, body_md)
|
||||||
|
return existing_doc_id, url
|
||||||
|
return await yuque_create_doc(token, default_repo, title, body_md)
|
||||||
|
|
||||||
|
if platform == "tencent":
|
||||||
|
await tencent_create_doc("", "", title, body_md)
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
raise RuntimeError(f"不支持的平台: {platform}")
|
||||||
@@ -44,7 +44,12 @@ async def generate_quote_excel(
|
|||||||
# Assume the first worksheet is used for the quote.
|
# Assume the first worksheet is used for the quote.
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
|
|
||||||
modules: List[Dict[str, Any]] = project_data.get("modules", [])
|
raw_modules: List[Any] = project_data.get("modules", [])
|
||||||
|
# Normalize: only dicts have .get(); coerce others to a minimal dict
|
||||||
|
modules: List[Dict[str, Any]] = [
|
||||||
|
m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"}
|
||||||
|
for i, m in enumerate(raw_modules, start=1)
|
||||||
|
]
|
||||||
total_amount = project_data.get("total_amount")
|
total_amount = project_data.get("total_amount")
|
||||||
total_hours = project_data.get("total_estimated_hours")
|
total_hours = project_data.get("total_estimated_hours")
|
||||||
notes = project_data.get("notes")
|
notes = project_data.get("notes")
|
||||||
@@ -157,7 +162,11 @@ async def generate_quote_pdf_from_data(
|
|||||||
|
|
||||||
c.setFont("Helvetica", 10)
|
c.setFont("Helvetica", 10)
|
||||||
|
|
||||||
modules: List[Dict[str, Any]] = project_data.get("modules", [])
|
raw_modules: List[Any] = project_data.get("modules", [])
|
||||||
|
modules = [
|
||||||
|
m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"}
|
||||||
|
for i, m in enumerate(raw_modules, start=1)
|
||||||
|
]
|
||||||
for idx, module in enumerate(modules, start=1):
|
for idx, module in enumerate(modules, start=1):
|
||||||
name = module.get("name", "")
|
name = module.get("name", "")
|
||||||
hours = module.get("estimated_hours", "")
|
hours = module.get("estimated_hours", "")
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import email
|
import email
|
||||||
|
import hashlib
|
||||||
import imaplib
|
import imaplib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import ssl
|
||||||
|
from datetime import date, datetime
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
# Ensure IMAP ID command is recognised by imaplib so we can spoof a
|
||||||
|
# desktop mail client (Foxmail/Outlook) for providers like NetEase/163.
|
||||||
|
imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED")
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.app.db import SessionLocal
|
from backend.app.db import SessionLocal
|
||||||
from backend.app.models import FinanceRecord
|
from backend.app.models import FinanceRecord
|
||||||
|
|
||||||
|
|
||||||
FINANCE_BASE_DIR = Path("data/finance")
|
FINANCE_BASE_DIR = Path("data/finance")
|
||||||
|
SYNC_DB_PATH = Path("data/finance/sync_history.db")
|
||||||
|
|
||||||
|
# Folder names for classification (invoices, receipts, statements)
|
||||||
|
INVOICES_DIR = "invoices"
|
||||||
|
RECEIPTS_DIR = "receipts"
|
||||||
|
STATEMENTS_DIR = "statements"
|
||||||
|
|
||||||
|
|
||||||
def _decode_header_value(value: str | None) -> str:
|
def _decode_header_value(value: str | None) -> str:
|
||||||
@@ -27,17 +44,21 @@ def _decode_header_value(value: str | None) -> str:
|
|||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
def _classify_type(subject: str) -> str:
|
def _classify_type(subject: str, filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
Classify finance document type based on subject keywords.
|
Classify finance document type. Returns: invoices, receipts, statements, others.
|
||||||
|
Maps to folders: invoices/, receipts/, statements/.
|
||||||
"""
|
"""
|
||||||
subject_lower = subject.lower()
|
text = f"{subject} {filename}".lower()
|
||||||
# 发票 / 开票类
|
# 发票 / 开票类
|
||||||
if any(k in subject for k in ["发票", "开票", "票据", "invoice"]):
|
if any(k in text for k in ["发票", "开票", "票据", "invoice", "fapiao"]):
|
||||||
return "invoices"
|
return "invoices"
|
||||||
|
# 回执
|
||||||
|
if any(k in text for k in ["回执", "签收单", "receipt"]):
|
||||||
|
return "receipts"
|
||||||
# 银行流水 / 账户明细 / 对公活期等
|
# 银行流水 / 账户明细 / 对公活期等
|
||||||
if any(
|
if any(
|
||||||
k in subject
|
k in text
|
||||||
for k in [
|
for k in [
|
||||||
"流水",
|
"流水",
|
||||||
"活期",
|
"活期",
|
||||||
@@ -50,9 +71,7 @@ def _classify_type(subject: str) -> str:
|
|||||||
"statement",
|
"statement",
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
return "bank_records"
|
return "statements"
|
||||||
if any(k in subject for k in ["回执", "receipt"]):
|
|
||||||
return "receipts"
|
|
||||||
return "others"
|
return "others"
|
||||||
|
|
||||||
|
|
||||||
@@ -71,16 +90,107 @@ def _parse_email_date(msg: email.message.Message) -> datetime:
|
|||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _run_invoice_ocr_sync(file_path: str, mime: str, raw_bytes: bytes) -> Tuple[float | None, str | None]:
|
||||||
|
"""Run extract_invoice_metadata from a sync context (new event loop). Handles PDF via first page image."""
|
||||||
|
from backend.app.services.ai_service import extract_invoice_metadata
|
||||||
|
from backend.app.services.invoice_upload import _pdf_first_page_to_image
|
||||||
|
|
||||||
|
if "pdf" in (mime or "").lower() or Path(file_path).suffix.lower() == ".pdf":
|
||||||
|
img_result = _pdf_first_page_to_image(raw_bytes)
|
||||||
|
if img_result:
|
||||||
|
image_bytes, img_mime = img_result
|
||||||
|
raw_bytes, mime = image_bytes, img_mime
|
||||||
|
# else keep raw_bytes and try anyway (may fail)
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(extract_invoice_metadata(raw_bytes, mime))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_invoice_file(
|
||||||
|
file_path: str,
|
||||||
|
amount: float | None,
|
||||||
|
billing_date: date | None,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Rename invoice file to YYYYMMDD_金额_原文件名.
|
||||||
|
Returns (new_file_name, new_file_path).
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
return (path.name, file_path)
|
||||||
|
date_str = (billing_date or date.today()).strftime("%Y%m%d")
|
||||||
|
amount_str = f"{amount:.2f}" if amount is not None else "0.00"
|
||||||
|
# Sanitize original name: take stem, limit length
|
||||||
|
orig_stem = path.stem[: 80] if len(path.stem) > 80 else path.stem
|
||||||
|
suffix = path.suffix
|
||||||
|
new_name = f"{date_str}_{amount_str}_{orig_stem}{suffix}"
|
||||||
|
new_path = path.parent / new_name
|
||||||
|
counter = 1
|
||||||
|
while new_path.exists():
|
||||||
|
new_path = path.parent / f"{date_str}_{amount_str}_{orig_stem}_{counter}{suffix}"
|
||||||
|
counter += 1
|
||||||
|
path.rename(new_path)
|
||||||
|
return (new_path.name, str(new_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sync_history_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS attachment_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id TEXT,
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
month TEXT,
|
||||||
|
doc_type TEXT,
|
||||||
|
file_name TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(message_id, file_hash)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_sync_history() -> bool:
|
||||||
|
"""是否有过同步记录;无记录视为首次同步,需拉全量;有记录则只拉增量(UNSEEN)。"""
|
||||||
|
if not SYNC_DB_PATH.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(SYNC_DB_PATH)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("SELECT 1 FROM attachment_history LIMIT 1")
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _save_attachment(
|
def _save_attachment(
|
||||||
msg: email.message.Message,
|
msg: email.message.Message,
|
||||||
month_str: str,
|
month_str: str,
|
||||||
doc_type: str,
|
) -> List[Tuple[str, str, str, bytes, str]]:
|
||||||
) -> List[Tuple[str, str]]:
|
|
||||||
"""
|
"""
|
||||||
Save PDF/image attachments and return list of (file_name, file_path).
|
Save PDF/image attachments.
|
||||||
|
Returns list of (file_name, file_path, mime, raw_bytes, doc_type).
|
||||||
|
raw_bytes kept for invoice OCR when doc_type == invoices.
|
||||||
|
|
||||||
|
同时使用 data/finance/sync_history.db 做增量去重:
|
||||||
|
- 以 (message_id, MD5(content)) 为唯一键,避免重复保存相同附件。
|
||||||
"""
|
"""
|
||||||
saved: List[Tuple[str, str]] = []
|
saved: List[Tuple[str, str, str, bytes, str]] = []
|
||||||
base_dir = _ensure_month_dir(month_str, doc_type)
|
|
||||||
|
msg_id = msg.get("Message-ID") or ""
|
||||||
|
subject = _decode_header_value(msg.get("Subject"))
|
||||||
|
|
||||||
|
SYNC_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(SYNC_DB_PATH)
|
||||||
|
try:
|
||||||
|
_ensure_sync_history_table(conn)
|
||||||
|
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
content_disposition = part.get("Content-Disposition", "")
|
content_disposition = part.get("Content-Disposition", "")
|
||||||
@@ -92,10 +202,11 @@ def _save_attachment(
|
|||||||
if not filename:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
content_type = part.get_content_type()
|
ext = Path(filename).suffix.lower()
|
||||||
maintype = part.get_content_maintype()
|
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".xlsx"):
|
||||||
|
continue
|
||||||
|
|
||||||
# Accept pdf and common images
|
maintype = part.get_content_maintype()
|
||||||
if maintype not in ("application", "image"):
|
if maintype not in ("application", "image"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -103,54 +214,267 @@ def _save_attachment(
|
|||||||
if not data:
|
if not data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 分类:基于主题 + 文件名
|
||||||
|
doc_type = _classify_type(subject, filename)
|
||||||
|
base_dir = _ensure_month_dir(month_str, doc_type)
|
||||||
|
|
||||||
|
# 增量去重:根据 (message_id, md5) 判断是否已同步过
|
||||||
|
file_hash = hashlib.md5(data).hexdigest() # nosec - content hash only
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT 1 FROM attachment_history WHERE message_id = ? AND file_hash = ?",
|
||||||
|
(msg_id, file_hash),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
mime = part.get_content_type() or "application/octet-stream"
|
||||||
file_path = base_dir / filename
|
file_path = base_dir / filename
|
||||||
# Ensure unique filename
|
|
||||||
counter = 1
|
counter = 1
|
||||||
while file_path.exists():
|
while file_path.exists():
|
||||||
stem = file_path.stem
|
stem, suffix = file_path.stem, file_path.suffix
|
||||||
suffix = file_path.suffix
|
|
||||||
file_path = base_dir / f"{stem}_{counter}{suffix}"
|
file_path = base_dir / f"{stem}_{counter}{suffix}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
with open(file_path, "wb") as f:
|
file_path.write_bytes(data)
|
||||||
f.write(data)
|
|
||||||
|
|
||||||
saved.append((filename, str(file_path)))
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO attachment_history
|
||||||
|
(message_id, file_hash, month, doc_type, file_name, file_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(msg_id, file_hash, month_str, doc_type, file_path.name, str(file_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
saved.append((file_path.name, str(file_path), mime, data, doc_type))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|
||||||
async def sync_finance_emails() -> List[Dict[str, Any]]:
|
def _decode_imap_utf7(s: str | bytes) -> str:
|
||||||
"""
|
"""Decode IMAP4 UTF-7 mailbox name (RFC 3501). Returns decoded string."""
|
||||||
Connect to IMAP, fetch unread finance-related emails, download attachments,
|
if isinstance(s, bytes):
|
||||||
save to filesystem and record FinanceRecord entries.
|
s = s.decode("ascii", errors="replace")
|
||||||
"""
|
if "&" not in s:
|
||||||
|
return s
|
||||||
|
parts = s.split("&")
|
||||||
|
out = [parts[0]]
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
chunk = parts[i]
|
||||||
|
if "-" in chunk:
|
||||||
|
u, rest = chunk.split("-", 1)
|
||||||
|
if u == "":
|
||||||
|
out.append("&")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# IMAP UTF-7: &BASE64- where BASE64 is modified (,+ instead of /,=)
|
||||||
|
pad = (4 - len(u) % 4) % 4
|
||||||
|
b = (u + "=" * pad).translate(str.maketrans(",+", "/="))
|
||||||
|
decoded = __import__("base64").b64decode(b).decode("utf-16-be")
|
||||||
|
out.append(decoded)
|
||||||
|
except Exception:
|
||||||
|
out.append("&" + chunk)
|
||||||
|
out.append(rest)
|
||||||
|
else:
|
||||||
|
out.append("&" + chunk)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
def _sync() -> List[Dict[str, Any]]:
|
|
||||||
host = os.getenv("IMAP_HOST")
|
def _parse_list_response(data: List[bytes]) -> List[Tuple[str, str]]:
|
||||||
user = os.getenv("IMAP_USER")
|
"""Parse imap.list() response to [(raw_name, decoded_name), ...]. Format: (flags) \"delim\" \"mailbox\"."""
|
||||||
password = os.getenv("IMAP_PASSWORD")
|
import shlex
|
||||||
port = int(os.getenv("IMAP_PORT", "993"))
|
result: List[Tuple[str, str]] = []
|
||||||
mailbox = os.getenv("IMAP_MAILBOX", "INBOX")
|
for line in data:
|
||||||
|
if not isinstance(line, bytes):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
line_str = line.decode("ascii", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parts = shlex.split(line_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
# Mailbox name is the last part (RFC 3501 LIST: (attrs) delim name)
|
||||||
|
raw = parts[-1]
|
||||||
|
decoded = _decode_imap_utf7(raw)
|
||||||
|
result.append((raw, decoded))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _list_mailboxes(imap: imaplib.IMAP4_SSL) -> List[Tuple[str, str]]:
|
||||||
|
"""List all mailboxes. Returns [(raw_name, decoded_name), ...]."""
|
||||||
|
status, data = imap.list()
|
||||||
|
if status != "OK" or not data:
|
||||||
|
return []
|
||||||
|
return _parse_list_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def list_mailboxes_for_config(host: str, port: int, user: str, password: str) -> List[Tuple[str, str]]:
|
||||||
|
"""Connect and list all mailboxes (for dropdown). Returns [(raw_name, decoded_name), ...]."""
|
||||||
|
with imaplib.IMAP4_SSL(host, int(port)) as imap:
|
||||||
|
imap.login(user, password)
|
||||||
|
return _list_mailboxes(imap)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
|
||||||
|
"""
|
||||||
|
Robust mailbox selection with deep discovery scan.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. LIST all folders, log raw lines for debugging.
|
||||||
|
2. Look for entry containing '\\Inbox' flag; if found, SELECT that folder.
|
||||||
|
3. Try standard candidates: user-configured name / INBOX / common UTF-7 收件箱编码.
|
||||||
|
4. As last resort, attempt SELECT on every listed folder and log which succeed/fail.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
name = (mailbox or "INBOX").strip() or "INBOX"
|
||||||
|
|
||||||
|
# 1) Discovery scan: list all folders and log raw entries
|
||||||
|
try:
|
||||||
|
status, data = imap.list()
|
||||||
|
if status != "OK" or not data:
|
||||||
|
logger.warning("IMAP LIST returned no data or non-OK status: %s", status)
|
||||||
|
data = []
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("IMAP LIST failed: %s", exc)
|
||||||
|
data = []
|
||||||
|
|
||||||
|
logger.info("IMAP Discovery Scan: listing all folders for mailbox=%s", name)
|
||||||
|
for raw in data:
|
||||||
|
logger.info("IMAP FOLDER RAW: %r", raw)
|
||||||
|
|
||||||
|
# 2) 优先按 \\Inbox 属性查找“真正的收件箱”
|
||||||
|
inbox_candidates: list[str] = []
|
||||||
|
for raw in data:
|
||||||
|
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw)
|
||||||
|
if "\\Inbox" not in line:
|
||||||
|
continue
|
||||||
|
m = re.search(r'"([^"]+)"\s*$', line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
folder_name = m.group(1)
|
||||||
|
inbox_candidates.append(folder_name)
|
||||||
|
|
||||||
|
# 3) 补充常规候选:配置名 / INBOX / 常见 UTF-7 收件箱编码
|
||||||
|
primary_names = [name, "INBOX"]
|
||||||
|
utf7_names = ["&XfJT0ZTx-"]
|
||||||
|
for nm in primary_names + utf7_names:
|
||||||
|
if nm not in inbox_candidates:
|
||||||
|
inbox_candidates.append(nm)
|
||||||
|
|
||||||
|
logger.info("IMAP Inbox candidate list (ordered): %r", inbox_candidates)
|
||||||
|
|
||||||
|
# 4) 依次尝试候选收件箱
|
||||||
|
for candidate in inbox_candidates:
|
||||||
|
for readonly in (False, True):
|
||||||
|
try:
|
||||||
|
status, _ = imap.select(candidate, readonly=readonly)
|
||||||
|
logger.info(
|
||||||
|
"IMAP SELECT candidate=%r readonly=%s -> %s", candidate, readonly, status
|
||||||
|
)
|
||||||
|
if status == "OK":
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"IMAP SELECT failed for candidate=%r readonly=%s: %s",
|
||||||
|
candidate,
|
||||||
|
readonly,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5) 最后手段:尝试 LIST 返回的每一个文件夹
|
||||||
|
logger.info("IMAP Fallback: trying SELECT on every listed folder...")
|
||||||
|
for raw in data:
|
||||||
|
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw)
|
||||||
|
m = re.search(r'"([^"]+)"\s*$', line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
folder_name = m.group(1)
|
||||||
|
for readonly in (False, True):
|
||||||
|
try:
|
||||||
|
status, _ = imap.select(folder_name, readonly=readonly)
|
||||||
|
logger.info(
|
||||||
|
"IMAP SELECT fallback folder=%r readonly=%s -> %s",
|
||||||
|
folder_name,
|
||||||
|
readonly,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
if status == "OK":
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"IMAP SELECT fallback failed for folder=%r readonly=%s: %s",
|
||||||
|
folder_name,
|
||||||
|
readonly,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.error("IMAP: unable to SELECT any inbox-like folder for mailbox=%s", name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[str, Any]]) -> None:
|
||||||
|
host = config.get("host")
|
||||||
|
user = config.get("user")
|
||||||
|
password = config.get("password")
|
||||||
|
port = int(config.get("port", 993))
|
||||||
|
mailbox = (config.get("mailbox") or "INBOX").strip() or "INBOX"
|
||||||
|
|
||||||
if not all([host, user, password]):
|
if not all([host, user, password]):
|
||||||
raise RuntimeError("IMAP_HOST, IMAP_USER, IMAP_PASSWORD must be set.")
|
return
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
# Use strict TLS context for modern protocols (TLS 1.2+)
|
||||||
|
tls_context = ssl.create_default_context()
|
||||||
|
|
||||||
with imaplib.IMAP4_SSL(host, port) as imap:
|
with imaplib.IMAP4_SSL(host, port, ssl_context=tls_context) as imap:
|
||||||
|
# Enable low-level IMAP debug output to backend logs to help diagnose
|
||||||
|
# handshake / protocol / mailbox selection issues with specific providers.
|
||||||
|
imap.debug = 4
|
||||||
imap.login(user, password)
|
imap.login(user, password)
|
||||||
imap.select(mailbox)
|
# NetEase / 163 等会对未知客户端静默限制 SELECT,这里通过 ID 命令伪装为常见桌面客户端。
|
||||||
|
try:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
id_str = (
|
||||||
|
'("name" "Foxmail" '
|
||||||
|
'"version" "7.2.25.170" '
|
||||||
|
'"vendor" "Tencent" '
|
||||||
|
'"os" "Windows" '
|
||||||
|
'"os-version" "10.0")'
|
||||||
|
)
|
||||||
|
logger.info("IMAP sending Foxmail-style ID: %s", id_str)
|
||||||
|
# Use low-level command so it works across Python versions.
|
||||||
|
typ, dat = imap._command("ID", id_str) # type: ignore[attr-defined]
|
||||||
|
logger.info("IMAP ID command result: %s %r", typ, dat)
|
||||||
|
except Exception as exc:
|
||||||
|
# ID 失败不应阻断登录,只记录日志,方便后续排查。
|
||||||
|
logging.getLogger(__name__).warning("IMAP ID command failed: %s", exc)
|
||||||
|
if not _select_mailbox(imap, mailbox):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法选择邮箱「{mailbox}」,请检查该账户的 Mailbox 配置(如 163 使用 INBOX)"
|
||||||
|
)
|
||||||
|
|
||||||
# Search for UNSEEN emails with finance related keywords in subject.
|
# 首次同步(历史库无记录):拉取全部邮件中的附件,由 attachment_history 去重
|
||||||
# Note: IMAP SEARCH is limited; here we search UNSEEN first then filter in Python.
|
# 已有历史:只拉取未读邮件,避免重复拉取
|
||||||
status, data = imap.search(None, "UNSEEN")
|
is_first_sync = not _has_sync_history()
|
||||||
|
search_criterion = "ALL" if is_first_sync else "UNSEEN"
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"Finance sync: %s (criterion=%s)",
|
||||||
|
"全量" if is_first_sync else "增量",
|
||||||
|
search_criterion,
|
||||||
|
)
|
||||||
|
status, data = imap.search(None, search_criterion)
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return results
|
return
|
||||||
|
|
||||||
id_list = data[0].split()
|
id_list = data[0].split()
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
for msg_id in id_list:
|
for msg_id in id_list:
|
||||||
status, msg_data = imap.fetch(msg_id, "(RFC822)")
|
status, msg_data = imap.fetch(msg_id, "(RFC822)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
@@ -158,46 +482,83 @@ async def sync_finance_emails() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
raw_email = msg_data[0][1]
|
raw_email = msg_data[0][1]
|
||||||
msg = email.message_from_bytes(raw_email)
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
subject = _decode_header_value(msg.get("Subject"))
|
|
||||||
doc_type = _classify_type(subject)
|
|
||||||
|
|
||||||
# Filter by keywords first
|
|
||||||
if doc_type == "others":
|
|
||||||
continue
|
|
||||||
|
|
||||||
dt = _parse_email_date(msg)
|
dt = _parse_email_date(msg)
|
||||||
month_str = dt.strftime("%Y-%m")
|
month_str = dt.strftime("%Y-%m")
|
||||||
|
|
||||||
saved_files = _save_attachment(msg, month_str, doc_type)
|
saved = _save_attachment(msg, month_str)
|
||||||
for file_name, file_path in saved_files:
|
for file_name, file_path, mime, raw_bytes, doc_type in saved:
|
||||||
|
final_name = file_name
|
||||||
|
final_path = file_path
|
||||||
|
amount = None
|
||||||
|
billing_date = None
|
||||||
|
|
||||||
|
if doc_type == "invoices":
|
||||||
|
amount, date_str = _run_invoice_ocr_sync(file_path, mime, raw_bytes)
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
billing_date = date.fromisoformat(date_str[:10])
|
||||||
|
except ValueError:
|
||||||
|
billing_date = date.today()
|
||||||
|
else:
|
||||||
|
billing_date = date.today()
|
||||||
|
final_name, final_path = _rename_invoice_file(
|
||||||
|
file_path, amount, billing_date
|
||||||
|
)
|
||||||
|
|
||||||
record = FinanceRecord(
|
record = FinanceRecord(
|
||||||
month=month_str,
|
month=month_str,
|
||||||
type=doc_type,
|
type=doc_type,
|
||||||
file_name=file_name,
|
file_name=final_name,
|
||||||
file_path=file_path,
|
file_path=final_path,
|
||||||
|
amount=amount,
|
||||||
|
billing_date=billing_date,
|
||||||
)
|
)
|
||||||
# NOTE: created_at defaults at DB layer
|
|
||||||
db.add(record)
|
db.add(record)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
results.append({
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"id": record.id,
|
"id": record.id,
|
||||||
"month": record.month,
|
"month": record.month,
|
||||||
"type": record.type,
|
"type": record.type,
|
||||||
"file_name": record.file_name,
|
"file_name": record.file_name,
|
||||||
"file_path": record.file_path,
|
"file_path": record.file_path,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
# Mark email as seen and flagged to avoid re-processing
|
|
||||||
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
|
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_finance_emails() -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Sync from all active email configs (data/email_configs.json).
|
||||||
|
Falls back to env vars if no configs. Classifies into invoices/, receipts/, statements/.
|
||||||
|
Invoices are renamed to YYYYMMDD_金额_原文件名 using OCR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sync() -> List[Dict[str, Any]]:
|
||||||
|
from backend.app.routers.email_configs import get_email_configs_for_sync
|
||||||
|
|
||||||
|
configs = get_email_configs_for_sync()
|
||||||
|
if not configs:
|
||||||
|
raise RuntimeError("未配置邮箱。请在 设置 → 邮箱账户 中添加,或配置 IMAP_* 环境变量。")
|
||||||
|
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
errors: List[str] = []
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for config in configs:
|
||||||
|
try:
|
||||||
|
_sync_one_account(config, db, results)
|
||||||
|
except Exception as e:
|
||||||
|
# 不让单个账户的异常中断全部同步,记录错误并继续其他账户。
|
||||||
|
user = config.get("user", "") or config.get("id", "")
|
||||||
|
errors.append(f"同步账户 {user} 失败: {e}")
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
if not results and errors:
|
||||||
|
# 所有账户都失败了,整体报错,前端可显示详细原因。
|
||||||
|
raise RuntimeError("; ".join(errors))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
return await asyncio.to_thread(_sync)
|
return await asyncio.to_thread(_sync)
|
||||||
@@ -205,7 +566,8 @@ async def sync_finance_emails() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
async def create_monthly_zip(month_str: str) -> str:
|
async def create_monthly_zip(month_str: str) -> str:
|
||||||
"""
|
"""
|
||||||
Zip the finance folder for a given month (YYYY-MM) and return the zip path.
|
Zip the finance folder for a given month (YYYY-MM).
|
||||||
|
Preserves folder structure (invoices/, receipts/, statements/, manual/) inside the zip.
|
||||||
"""
|
"""
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
@@ -227,4 +589,3 @@ async def create_monthly_zip(month_str: str) -> str:
|
|||||||
return str(zip_path)
|
return str(zip_path)
|
||||||
|
|
||||||
return await asyncio.to_thread(_zip)
|
return await asyncio.to_thread(_zip)
|
||||||
|
|
||||||
|
|||||||
90
backend/app/services/invoice_upload.py
Normal file
90
backend/app/services/invoice_upload.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Manual invoice upload: save file, optionally run AI vision to extract amount/date.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from backend.app.services.ai_service import extract_invoice_metadata
|
||||||
|
|
||||||
|
FINANCE_BASE = Path("data/finance")
|
||||||
|
ALLOWED_IMAGE = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
ALLOWED_PDF = {".pdf"}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_month() -> str:
|
||||||
|
return datetime.utcnow().strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_first_page_to_image(pdf_bytes: bytes) -> Tuple[bytes, str] | None:
|
||||||
|
"""Render first page of PDF to PNG bytes. Returns (bytes, 'image/png') or None on error."""
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
if doc.page_count == 0:
|
||||||
|
doc.close()
|
||||||
|
return None
|
||||||
|
page = doc[0]
|
||||||
|
pix = page.get_pixmap(dpi=150)
|
||||||
|
png_bytes = pix.tobytes("png")
|
||||||
|
doc.close()
|
||||||
|
return (png_bytes, "image/png")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def process_invoice_upload(
|
||||||
|
file: UploadFile,
|
||||||
|
) -> Tuple[str, str, str, float | None, date | None]:
|
||||||
|
"""
|
||||||
|
Save uploaded file to data/finance/{YYYY-MM}/manual/, run OCR for amount/date.
|
||||||
|
Returns (file_name, file_path, month_str, amount, billing_date).
|
||||||
|
"""
|
||||||
|
month_str = _current_month()
|
||||||
|
manual_dir = FINANCE_BASE / month_str / "manual"
|
||||||
|
manual_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
raw = await file.read()
|
||||||
|
filename = file.filename or "upload"
|
||||||
|
suf = Path(filename).suffix.lower()
|
||||||
|
|
||||||
|
if suf in ALLOWED_IMAGE:
|
||||||
|
image_bytes, mime = raw, (file.content_type or "image/jpeg")
|
||||||
|
if "png" in (suf or ""):
|
||||||
|
mime = "image/png"
|
||||||
|
amount, date_str = await extract_invoice_metadata(image_bytes, mime)
|
||||||
|
elif suf in ALLOWED_PDF:
|
||||||
|
image_result = _pdf_first_page_to_image(raw)
|
||||||
|
if image_result:
|
||||||
|
image_bytes, mime = image_result
|
||||||
|
amount, date_str = await extract_invoice_metadata(image_bytes, mime)
|
||||||
|
else:
|
||||||
|
amount, date_str = None, None
|
||||||
|
# Save original PDF
|
||||||
|
else:
|
||||||
|
amount, date_str = None, None
|
||||||
|
|
||||||
|
# Unique filename
|
||||||
|
dest = manual_dir / filename
|
||||||
|
counter = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = manual_dir / f"{dest.stem}_{counter}{dest.suffix}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
dest.write_bytes(raw)
|
||||||
|
file_path = str(dest)
|
||||||
|
file_name = dest.name
|
||||||
|
|
||||||
|
billing_date = None
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
billing_date = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if billing_date is None:
|
||||||
|
billing_date = date.today()
|
||||||
|
|
||||||
|
return (file_name, file_path, month_str, amount, billing_date)
|
||||||
42
docker-compose.dev.yml
Normal file
42
docker-compose.dev.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 开发模式:代码挂载 + 内部热重载,无需重建镜像即可生效(类似 K8s 仅更新配置/代码并滚动重启)
|
||||||
|
# 使用方式: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
# 或: ./docker_dev.sh dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: ops-core-backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
# 挂载代码:宿主机修改后由 uvicorn --reload 自动重启进程,无需重建容器
|
||||||
|
- ./backend:/app/backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
# 仅重启进程,不重启容器;代码变更由 reload 自动加载
|
||||||
|
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ops-core-frontend
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
# 保留容器内 node_modules,避免被宿主机目录覆盖
|
||||||
|
- frontend_node_modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
|
# 开发模式:先装依赖(volume 首次为空),再 dev,代码变更热更新
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_node_modules:
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# 生产/默认:分层构建,仅代码变更时只重建最后一层。
|
||||||
|
# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
# 或执行: ./docker_dev.sh dev
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 使用 Docker Compose 一键构建并启动 FastAPI + Next.js
|
# Docker 开发与部署:支持仅更新变动内容、动态加载,避免每次全量重建
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "${SCRIPT_DIR}"
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
echo "[Ops-Core] 使用 Docker 构建并启动服务 (backend + frontend)..."
|
COMPOSE_BASE="docker compose -f docker-compose.yml"
|
||||||
docker compose -f docker-compose.yml up --build
|
COMPOSE_DEV="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
|
||||||
|
|
||||||
# 如需后台模式,可改为:
|
usage() {
|
||||||
# docker compose -f docker-compose.yml up --build -d
|
echo "用法: $0 [mode]"
|
||||||
|
echo ""
|
||||||
|
echo " (无参数) 默认构建并启动(生产模式,分层构建仅重建变更层)"
|
||||||
|
echo " dev 开发模式:挂载代码 + 热重载(前台,Ctrl+C 会停容器)"
|
||||||
|
echo " dev-bg 开发模式后台运行,Ctrl+C 不会停容器,停止用: $0 down"
|
||||||
|
echo " restart 仅重启容器内服务,不重建镜像"
|
||||||
|
echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)"
|
||||||
|
echo " down 停止并移除容器"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-up}" in
|
||||||
|
up|"")
|
||||||
|
echo "[Ops-Core] 构建并启动(生产模式)..."
|
||||||
|
${COMPOSE_BASE} up --build
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
echo "[Ops-Core] 开发模式:代码挂载 + 热重载,无需重建(Ctrl+C 会停止容器)..."
|
||||||
|
${COMPOSE_DEV} up --build
|
||||||
|
;;
|
||||||
|
dev-bg)
|
||||||
|
echo "[Ops-Core] 开发模式(后台):同上,但 Ctrl+C 不会停容器,需用 ./docker_dev.sh down 停止..."
|
||||||
|
${COMPOSE_DEV} up --build -d
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
echo "[Ops-Core] 仅重启服务(不重建镜像)..."
|
||||||
|
${COMPOSE_BASE} restart backend frontend
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
echo "[Ops-Core] 仅构建镜像(变更少时只重建代码层)..."
|
||||||
|
${COMPOSE_BASE} build
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
${COMPOSE_BASE} down
|
||||||
|
${COMPOSE_DEV} down 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知参数: $1"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
4
frontend/.npmrc
Normal file
4
frontend/.npmrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 国内镜像,加快安装、避免卡死(Docker 内已单独配置,此文件供本地/CI 使用)
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
fetch-retries=5
|
||||||
|
fetch-timeout=60000
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
|
# 分层构建:依赖与代码分离,仅代码变更时只重建 COPY 及以后层
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
# 使用国内镜像源,加快安装并减少网络卡死
|
||||||
RUN npm install
|
RUN npm config set registry https://registry.npmmirror.com \
|
||||||
|
&& npm config set fetch-retries 5 \
|
||||||
|
&& npm config set fetch-timeout 60000 \
|
||||||
|
&& npm config set fetch-retry-mintimeout 10000
|
||||||
|
|
||||||
|
# 依赖层:只有 package.json / package-lock 变更时才重建
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --prefer-offline --no-audit --progress=false
|
||||||
|
|
||||||
|
# 代码层:业务代码变更只重建此层及后续 build
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
503
frontend/app/(main)/finance/page.tsx
Normal file
503
frontend/app/(main)/finance/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
apiBase,
|
||||||
|
financeApi,
|
||||||
|
type FinanceRecordRead,
|
||||||
|
type FinanceSyncResponse,
|
||||||
|
type FinanceSyncResult,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function FinancePage() {
|
||||||
|
const [months, setMonths] = useState<string[]>([]);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||||
|
const [records, setRecords] = useState<FinanceRecordRead[]>([]);
|
||||||
|
const [loadingMonths, setLoadingMonths] = useState(true);
|
||||||
|
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
|
||||||
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [reviewRecord, setReviewRecord] = useState<FinanceRecordRead | null>(null);
|
||||||
|
const [reviewAmount, setReviewAmount] = useState("");
|
||||||
|
const [reviewDate, setReviewDate] = useState("");
|
||||||
|
const [savingReview, setSavingReview] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const loadMonths = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await financeApi.listMonths();
|
||||||
|
setMonths(list);
|
||||||
|
if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载月份列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingMonths(false);
|
||||||
|
}
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
const loadRecords = useCallback(async () => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
setRecords([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingRecords(true);
|
||||||
|
try {
|
||||||
|
const list = await financeApi.listRecords(selectedMonth);
|
||||||
|
setRecords(list);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载记录失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingRecords(false);
|
||||||
|
}
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMonths();
|
||||||
|
}, [loadMonths]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecords();
|
||||||
|
}, [loadRecords]);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
toast.loading("正在同步邮箱…", { id: "finance-sync" });
|
||||||
|
try {
|
||||||
|
const res: FinanceSyncResponse = await financeApi.sync();
|
||||||
|
setLastSync(res);
|
||||||
|
toast.dismiss("finance-sync");
|
||||||
|
if (res.new_files > 0) {
|
||||||
|
toast.success(`发现 ${res.new_files} 个新文件`);
|
||||||
|
await loadMonths();
|
||||||
|
if (selectedMonth) await loadRecords();
|
||||||
|
} else {
|
||||||
|
toast.info("收件箱已是最新,无新文件");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.dismiss("finance-sync");
|
||||||
|
toast.error(e instanceof Error ? e.message : "同步失败");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUploadDialog = useCallback(() => {
|
||||||
|
setUploadFile(null);
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setReviewRecord(null);
|
||||||
|
setReviewAmount("");
|
||||||
|
setReviewDate("");
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||||
|
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) {
|
||||||
|
toast.error("仅支持 PDF、JPG、PNG、WEBP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setUploadFile(file);
|
||||||
|
setReviewRecord(null);
|
||||||
|
setReviewAmount("");
|
||||||
|
setReviewDate("");
|
||||||
|
if (file.type.startsWith("image/")) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPreviewUrl(url);
|
||||||
|
} else {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadSubmit = async () => {
|
||||||
|
if (!uploadFile) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const record = await financeApi.uploadInvoice(uploadFile);
|
||||||
|
setReviewRecord(record);
|
||||||
|
setReviewAmount(record.amount != null ? String(record.amount) : "");
|
||||||
|
setReviewDate(record.billing_date || "");
|
||||||
|
toast.success("已上传,请核对金额与日期");
|
||||||
|
await loadMonths();
|
||||||
|
if (selectedMonth === record.month) await loadRecords();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewSave = async () => {
|
||||||
|
if (!reviewRecord) return;
|
||||||
|
const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null;
|
||||||
|
const billing_date = reviewDate.trim() || null;
|
||||||
|
setSavingReview(true);
|
||||||
|
try {
|
||||||
|
await financeApi.updateRecord(reviewRecord.id, { amount, billing_date });
|
||||||
|
toast.success("已保存");
|
||||||
|
setUploadDialogOpen(false);
|
||||||
|
resetUploadDialog();
|
||||||
|
if (selectedMonth) await loadRecords();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingReview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadZip = async () => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
toast.error("请先选择月份");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await financeApi.downloadMonth(selectedMonth);
|
||||||
|
toast.success(`已下载 ${selectedMonth}.zip`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "下载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (s: string) =>
|
||||||
|
new Date(s).toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
invoices: "发票",
|
||||||
|
bank_records: "流水",
|
||||||
|
statements: "流水",
|
||||||
|
receipts: "回执",
|
||||||
|
manual: "手动上传",
|
||||||
|
others: "其他",
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
|
||||||
|
const totalInvoicesThisMonth = records.filter(
|
||||||
|
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
|
||||||
|
).reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">财务归档</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
从网易邮箱同步发票、回执、流水等附件,或手动上传发票
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{selectedMonth && (
|
||||||
|
<Card className="py-2 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground">本月发票合计</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setUploadDialogOpen(true);
|
||||||
|
resetUploadDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span className="ml-2">上传发票</span>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSync} disabled={syncing} size="default">
|
||||||
|
{syncing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-2">同步邮箱</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Invoice Dialog */}
|
||||||
|
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
|
||||||
|
setUploadDialogOpen(open);
|
||||||
|
if (!open) resetUploadDialog();
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!reviewRecord ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt="预览" className="max-h-48 mx-auto object-contain" />
|
||||||
|
) : uploadFile ? (
|
||||||
|
<p className="text-sm font-medium">{uploadFile.name}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">点击或拖拽 PDF/图片到此处</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUploadSubmit} disabled={!uploadFile || uploading}>
|
||||||
|
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
<span className="ml-2">上传并识别</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{previewUrl && (
|
||||||
|
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>金额</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={reviewAmount}
|
||||||
|
onChange={(e) => setReviewAmount(e.target.value)}
|
||||||
|
placeholder="可手动修改"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>开票日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={reviewDate}
|
||||||
|
onChange={(e) => setReviewDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
|
||||||
|
继续上传
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReviewSave} disabled={savingReview}>
|
||||||
|
{savingReview ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
<span className="ml-2">保存并关闭</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Sync History / Last sync */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
同步记录
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
{lastSync !== null ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
最近一次同步:发现 <strong>{lastSync.new_files}</strong> 个新文件
|
||||||
|
</p>
|
||||||
|
{lastSync.details && lastSync.details.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{Object.entries(
|
||||||
|
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
|
||||||
|
(acc, item) => {
|
||||||
|
const t = item.type || "others";
|
||||||
|
if (!acc[t]) acc[t] = [];
|
||||||
|
acc[t].push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
).map(([t, items]) => (
|
||||||
|
<div key={t}>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{typeLabel[t] ?? t}({items.length})
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 ml-4 list-disc space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id}>
|
||||||
|
{it.file_name}
|
||||||
|
<span className="ml-1 text-[11px] text-muted-foreground/80">
|
||||||
|
[{it.month}]
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
点击「同步邮箱」后,将显示本次同步结果
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Month + Download */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-base">按月份查看</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedMonth}
|
||||||
|
onValueChange={setSelectedMonth}
|
||||||
|
disabled={loadingMonths}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="选择月份" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{months.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadZip}
|
||||||
|
disabled={!selectedMonth || records.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="ml-1.5">下载本月全部 (.zip)</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingRecords ? (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1 py-4">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。
|
||||||
|
</p>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>文件名</TableHead>
|
||||||
|
<TableHead>金额</TableHead>
|
||||||
|
<TableHead>开票/归档时间</TableHead>
|
||||||
|
<TableHead className="w-[100px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{typeLabel[r.type] ?? r.type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{r.file_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.amount != null
|
||||||
|
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{r.billing_date || formatDate(r.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow className="bg-muted/30 font-medium">
|
||||||
|
<TableCell colSpan={2}>本月合计</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
391
frontend/app/(main)/settings/ai/page.tsx
Normal file
391
frontend/app/(main)/settings/ai/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
aiSettingsApi,
|
||||||
|
type AIConfig,
|
||||||
|
type AIConfigListItem,
|
||||||
|
type AIConfigCreate,
|
||||||
|
type AIConfigUpdate,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { Loader2, Zap, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const PROVIDERS = ["OpenAI", "DeepSeek", "Custom"];
|
||||||
|
|
||||||
|
export default function SettingsAIPage() {
|
||||||
|
const [list, setList] = useState<AIConfigListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [provider, setProvider] = useState("OpenAI");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
|
||||||
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
const [modelName, setModelName] = useState("gpt-4o-mini");
|
||||||
|
const [temperature, setTemperature] = useState("0.2");
|
||||||
|
const [systemPromptOverride, setSystemPromptOverride] = useState("");
|
||||||
|
|
||||||
|
const loadList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await aiSettingsApi.list();
|
||||||
|
setList(data);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载模型列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadList();
|
||||||
|
}, [loadList]);
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormName("");
|
||||||
|
setProvider("OpenAI");
|
||||||
|
setApiKey("");
|
||||||
|
setApiKeyConfigured(false);
|
||||||
|
setBaseUrl("");
|
||||||
|
setModelName("gpt-4o-mini");
|
||||||
|
setTemperature("0.2");
|
||||||
|
setSystemPromptOverride("");
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const c = await aiSettingsApi.getById(id);
|
||||||
|
setEditingId(id);
|
||||||
|
setFormName(c.name || "");
|
||||||
|
setProvider(c.provider || "OpenAI");
|
||||||
|
setApiKey("");
|
||||||
|
setApiKeyConfigured(!!(c.api_key && c.api_key.length > 0));
|
||||||
|
setBaseUrl(c.base_url || "");
|
||||||
|
setModelName(c.model_name || "gpt-4o-mini");
|
||||||
|
setTemperature(String(c.temperature ?? 0.2));
|
||||||
|
setSystemPromptOverride(c.system_prompt_override || "");
|
||||||
|
setDialogOpen(true);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载配置失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
const payload: AIConfigUpdate = {
|
||||||
|
name: formName.trim() || undefined,
|
||||||
|
provider: provider || undefined,
|
||||||
|
base_url: baseUrl.trim() || undefined,
|
||||||
|
model_name: modelName.trim() || undefined,
|
||||||
|
temperature: parseFloat(temperature),
|
||||||
|
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (apiKey.trim()) payload.api_key = apiKey.trim();
|
||||||
|
await aiSettingsApi.update(editingId, payload);
|
||||||
|
toast.success("已更新");
|
||||||
|
} else {
|
||||||
|
const payload: AIConfigCreate = {
|
||||||
|
name: formName.trim() || undefined,
|
||||||
|
provider: provider || undefined,
|
||||||
|
api_key: apiKey.trim() || undefined,
|
||||||
|
base_url: baseUrl.trim() || undefined,
|
||||||
|
model_name: modelName.trim() || undefined,
|
||||||
|
temperature: parseFloat(temperature),
|
||||||
|
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||||||
|
};
|
||||||
|
await aiSettingsApi.create(payload);
|
||||||
|
toast.success("已添加");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadList();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await aiSettingsApi.activate(id);
|
||||||
|
toast.success("已选用该模型");
|
||||||
|
await loadList();
|
||||||
|
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "选用失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确定删除该模型配置?")) return;
|
||||||
|
try {
|
||||||
|
await aiSettingsApi.delete(id);
|
||||||
|
toast.success("已删除");
|
||||||
|
await loadList();
|
||||||
|
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (id: string) => {
|
||||||
|
setTesting(id);
|
||||||
|
try {
|
||||||
|
const res = await aiSettingsApi.test(id);
|
||||||
|
toast.success(res.message ? `连接成功:${res.message}` : "连接成功");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "连接失败");
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← 设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>已配置的模型</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
在此查看、选用或编辑多套 AI 模型配置;需求解析与测试将使用当前选用的配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="ml-2">添加模型</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
暂无模型配置。点击「添加模型」填写 API Key、模型名称等,保存后即可在列表中选用。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>提供商</TableHead>
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead className="w-[200px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.name || "未命名"}
|
||||||
|
{item.is_active && (
|
||||||
|
<span className="ml-2 text-xs text-primary flex items-center gap-0.5">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" /> 当前选用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.provider}</TableCell>
|
||||||
|
<TableCell>{item.model_name || "—"}</TableCell>
|
||||||
|
<TableCell>{item.api_key_configured ? "已配置" : "未配置"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{!item.is_active && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleActivate(item.id)}
|
||||||
|
>
|
||||||
|
选用
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(item.id)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTest(item.id)}
|
||||||
|
disabled={testing === item.id}
|
||||||
|
title="测试连接"
|
||||||
|
>
|
||||||
|
{testing === item.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
title="删除"
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "编辑模型配置" : "添加模型配置"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="ai-name">配置名称(便于区分)</Label>
|
||||||
|
<Input
|
||||||
|
id="ai-name"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="如:OpenAI 生产、DeepSeek 备用"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="provider">提供商</Label>
|
||||||
|
<Select value={provider} onValueChange={setProvider}>
|
||||||
|
<SelectTrigger id="provider">
|
||||||
|
<SelectValue placeholder="选择提供商" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="api_key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="api_key"
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="base_url">Base URL(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="base_url"
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="model_name">模型名称</Label>
|
||||||
|
<Input
|
||||||
|
id="model_name"
|
||||||
|
value={modelName}
|
||||||
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
|
placeholder="gpt-4o-mini / deepseek-chat 等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="temperature">Temperature (0–2)</Label>
|
||||||
|
<Input
|
||||||
|
id="temperature"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
value={temperature}
|
||||||
|
onChange={(e) => setTemperature(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="system_prompt_override">系统提示词覆盖(可选)</Label>
|
||||||
|
<textarea
|
||||||
|
id="system_prompt_override"
|
||||||
|
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
value={systemPromptOverride}
|
||||||
|
onChange={(e) => setSystemPromptOverride(e.target.value)}
|
||||||
|
placeholder="留空则使用默认"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||||
|
{editingId ? "保存" : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal file
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
cloudDocConfigApi,
|
||||||
|
type CloudDocConfigRead,
|
||||||
|
type CloudDocConfigUpdate,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { Loader2, Save, FileStack } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function SettingsCloudDocConfigPage() {
|
||||||
|
const [config, setConfig] = useState<CloudDocConfigRead | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState<CloudDocConfigUpdate>({
|
||||||
|
feishu: { app_id: "", app_secret: "" },
|
||||||
|
yuque: { token: "", default_repo: "" },
|
||||||
|
tencent: { client_id: "", client_secret: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await cloudDocConfigApi.get();
|
||||||
|
setConfig(data);
|
||||||
|
setForm({
|
||||||
|
feishu: { app_id: data.feishu.app_id, app_secret: "" },
|
||||||
|
yuque: { token: "", default_repo: data.yuque.default_repo },
|
||||||
|
tencent: { client_id: data.tencent.client_id, client_secret: "" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("加载云文档配置失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: CloudDocConfigUpdate = {};
|
||||||
|
if (form.feishu?.app_id !== undefined) payload.feishu = { app_id: form.feishu.app_id };
|
||||||
|
if (form.feishu?.app_secret !== undefined && form.feishu.app_secret !== "")
|
||||||
|
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
|
||||||
|
if (form.yuque?.token !== undefined && form.yuque.token !== "")
|
||||||
|
payload.yuque = { token: form.yuque.token };
|
||||||
|
if (form.yuque?.default_repo !== undefined)
|
||||||
|
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
|
||||||
|
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
|
||||||
|
if (form.tencent?.client_secret !== undefined && form.tencent.client_secret !== "")
|
||||||
|
payload.tencent = { ...payload.tencent, client_secret: form.tencent.client_secret };
|
||||||
|
await cloudDocConfigApi.update(payload);
|
||||||
|
toast.success("已保存");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl flex items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">加载中…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← 设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileStack className="h-5 w-5" />
|
||||||
|
云文档配置
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
配置飞书、语雀、腾讯文档的 API 凭证,用于在工作台「推送到云文档」时创建/更新在线文档。
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 飞书 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">飞书 (Feishu)</h3>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>App ID</Label>
|
||||||
|
<Input
|
||||||
|
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
feishu: { ...f.feishu, app_id: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="在飞书开放平台创建应用后获取"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>App Secret</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.feishu?.app_secret ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
feishu: { ...f.feishu, app_secret: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 语雀 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">语雀 (Yuque)</h3>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Personal Access Token</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.yuque?.token ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
yuque: { ...f.yuque, token: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>默认知识库 (namespace)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
yuque: { ...f.yuque, default_repo: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="如:your_username/repo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 腾讯文档 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">腾讯文档 (Tencent)</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
腾讯文档需 OAuth 用户授权,当前版本仅保留配置项,推送功能请先用飞书或语雀。
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Client ID</Label>
|
||||||
|
<Input
|
||||||
|
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
tencent: { ...f.tencent, client_id: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="开放平台应用 Client ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Client Secret</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.tencent?.client_secret ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
tencent: { ...f.tencent, client_secret: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal file
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
cloudDocsApi,
|
||||||
|
type CloudDocLinkRead,
|
||||||
|
type CloudDocLinkCreate,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { FileStack, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function SettingsCloudDocsPage() {
|
||||||
|
const [links, setLinks] = useState<CloudDocLinkRead[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: "", url: "" });
|
||||||
|
|
||||||
|
const loadLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await cloudDocsApi.list();
|
||||||
|
setLinks(list);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载云文档列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLinks();
|
||||||
|
}, [loadLinks]);
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({ name: "", url: "" });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: CloudDocLinkRead) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setForm({ name: item.name, url: item.url });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim() || !form.url.trim()) {
|
||||||
|
toast.error("请填写名称和链接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await cloudDocsApi.update(editingId, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
});
|
||||||
|
toast.success("已更新");
|
||||||
|
} else {
|
||||||
|
const payload: CloudDocLinkCreate = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
};
|
||||||
|
await cloudDocsApi.create(payload);
|
||||||
|
toast.success("已添加");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadLinks();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确定删除该云文档入口?")) return;
|
||||||
|
try {
|
||||||
|
await cloudDocsApi.delete(id);
|
||||||
|
toast.success("已删除");
|
||||||
|
await loadLinks();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← 设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileStack className="h-5 w-5" />
|
||||||
|
云文档快捷入口
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
添加腾讯文档、飞书、语雀等云文档登录/入口链接,侧栏可快速打开。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="ml-2">添加入口</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : links.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
暂无云文档入口。添加后将显示在左侧边栏「云文档」区域,点击即可在新标签页打开。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>链接</TableHead>
|
||||||
|
<TableHead className="w-[120px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{links.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline truncate max-w-[280px] inline-block"
|
||||||
|
>
|
||||||
|
{item.url}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
title="打开"
|
||||||
|
>
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId ? "编辑云文档入口" : "添加云文档入口"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cloud-doc-name">显示名称</Label>
|
||||||
|
<Input
|
||||||
|
id="cloud-doc-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="如:腾讯文档、飞书、语雀"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cloud-doc-url">登录/入口链接</Label>
|
||||||
|
<Input
|
||||||
|
id="cloud-doc-url"
|
||||||
|
type="url"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||||
|
{editingId ? "保存" : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
frontend/app/(main)/settings/email/page.tsx
Normal file
364
frontend/app/(main)/settings/email/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
emailConfigsApi,
|
||||||
|
type EmailConfigRead,
|
||||||
|
type EmailConfigCreate,
|
||||||
|
type EmailConfigUpdate,
|
||||||
|
type EmailFolder,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function SettingsEmailPage() {
|
||||||
|
const [configs, setConfigs] = useState<EmailConfigRead[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
host: "",
|
||||||
|
port: "993",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
mailbox: "INBOX",
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
const [folders, setFolders] = useState<EmailFolder[] | null>(null);
|
||||||
|
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await emailConfigsApi.list();
|
||||||
|
setConfigs(list);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载邮箱列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({
|
||||||
|
host: "",
|
||||||
|
port: "993",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
mailbox: "INBOX",
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (c: EmailConfigRead) => {
|
||||||
|
setEditingId(c.id);
|
||||||
|
setFolders(null);
|
||||||
|
setForm({
|
||||||
|
host: c.host,
|
||||||
|
port: String(c.port),
|
||||||
|
user: c.user,
|
||||||
|
password: "",
|
||||||
|
mailbox: c.mailbox || "INBOX",
|
||||||
|
active: c.active,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
setFoldersLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await emailConfigsApi.listFolders(editingId);
|
||||||
|
setFolders(res.folders);
|
||||||
|
if (res.folders.length > 0 && !form.mailbox) {
|
||||||
|
const inbox = res.folders.find((f) => f.decoded === "INBOX" || f.decoded === "收件箱");
|
||||||
|
if (inbox) setForm((f) => ({ ...f, mailbox: inbox.decoded }));
|
||||||
|
}
|
||||||
|
toast.success(`已加载 ${res.folders.length} 个邮箱夹`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "获取邮箱列表失败");
|
||||||
|
} finally {
|
||||||
|
setFoldersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
const payload: EmailConfigUpdate = {
|
||||||
|
host: form.host,
|
||||||
|
port: parseInt(form.port, 10) || 993,
|
||||||
|
user: form.user,
|
||||||
|
mailbox: form.mailbox,
|
||||||
|
active: form.active,
|
||||||
|
};
|
||||||
|
if (form.password) payload.password = form.password;
|
||||||
|
await emailConfigsApi.update(editingId, payload);
|
||||||
|
toast.success("已更新");
|
||||||
|
} else {
|
||||||
|
await emailConfigsApi.create({
|
||||||
|
host: form.host,
|
||||||
|
port: parseInt(form.port, 10) || 993,
|
||||||
|
user: form.user,
|
||||||
|
password: form.password,
|
||||||
|
mailbox: form.mailbox,
|
||||||
|
active: form.active,
|
||||||
|
});
|
||||||
|
toast.success("已添加");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确定删除该邮箱账户?")) return;
|
||||||
|
try {
|
||||||
|
await emailConfigsApi.delete(id);
|
||||||
|
toast.success("已删除");
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← 设置
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
邮箱账户
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
配置多个邮箱用于财务邮件同步(发票、回执、流水)。同步时将遍历所有已启用的账户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="ml-2">添加账户</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
暂无邮箱账户。添加后将在「财务归档」同步时使用;未添加时使用环境变量 IMAP_*。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Host</TableHead>
|
||||||
|
<TableHead>端口</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>Mailbox</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="w-[120px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{configs.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.host}</TableCell>
|
||||||
|
<TableCell>{c.port}</TableCell>
|
||||||
|
<TableCell>{c.user}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.mailbox}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.active ? (
|
||||||
|
<span className="text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded">
|
||||||
|
启用
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">禁用</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(c.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-sm">网易 163 邮箱配置说明</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
|
||||||
|
<p><strong>IMAP 服务器:</strong> imap.163.com,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/SMTP 服务」。</p>
|
||||||
|
<p><strong>密码:</strong> 使用「授权码」而非登录密码。在 设置 → POP3/SMTP/IMAP → 授权码管理 中生成。</p>
|
||||||
|
<p><strong>邮箱夹:</strong> 填 INBOX 或 收件箱;若同步失败,请编辑该账户并点击「获取邮箱列表」选择「收件箱」或目标标签。</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "编辑邮箱账户" : "添加邮箱账户"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>IMAP Host</Label>
|
||||||
|
<Input
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
||||||
|
placeholder="imap.163.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>端口</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))}
|
||||||
|
placeholder="993"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>邮箱 / 用户名</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={form.user}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>密码 / 授权码 {editingId && "(留空则不修改)"}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder={editingId ? "••••••••" : "请输入"}
|
||||||
|
autoComplete="off"
|
||||||
|
required={!editingId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>邮箱夹 / 自定义标签 (Mailbox)</Label>
|
||||||
|
{editingId && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadFolders}
|
||||||
|
disabled={foldersLoading}
|
||||||
|
>
|
||||||
|
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
|
||||||
|
<span className="ml-1">获取邮箱列表</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{folders && folders.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={form.mailbox}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择邮箱夹" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<SelectItem key={f.raw} value={f.decoded}>
|
||||||
|
{f.decoded}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={form.mailbox}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
|
||||||
|
placeholder="INBOX、收件箱或自定义标签(163 等若 INBOX 失败会自动尝试收件箱)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="active"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="active">启用(参与同步)</Label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
<span className="ml-2">{editingId ? "保存" : "添加"}</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/app/(main)/settings/page.tsx
Normal file
50
frontend/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl">
|
||||||
|
<h1 className="text-xl font-semibold">设置</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">系统与业务配置</p>
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/templates" className="flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
模板管理(报价 Excel / 合同 Word)
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/ai" className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
AI 模型配置
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/email" className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
邮箱账户(多账户同步)
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/cloud-docs" className="flex items-center gap-2">
|
||||||
|
<FileStack className="h-4 w-4" />
|
||||||
|
云文档快捷入口
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/cloud-doc-config" className="flex items-center gap-2">
|
||||||
|
<FileStack className="h-4 w-4" />
|
||||||
|
云文档配置(飞书 / 语雀 / 腾讯文档 API 凭证)
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
|
<Link href="/settings/portal-links" className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
快捷门户
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal file
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
portalLinksApi,
|
||||||
|
type PortalLinkRead,
|
||||||
|
type PortalLinkCreate,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { Globe, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function SettingsPortalLinksPage() {
|
||||||
|
const [links, setLinks] = useState<PortalLinkRead[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: "", url: "" });
|
||||||
|
|
||||||
|
const loadLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await portalLinksApi.list();
|
||||||
|
setLinks(list);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载快捷门户列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLinks();
|
||||||
|
}, [loadLinks]);
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({ name: "", url: "" });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: PortalLinkRead) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setForm({ name: item.name, url: item.url });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim() || !form.url.trim()) {
|
||||||
|
toast.error("请填写名称和链接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await portalLinksApi.update(editingId, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
});
|
||||||
|
toast.success("已更新");
|
||||||
|
} else {
|
||||||
|
const payload: PortalLinkCreate = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
};
|
||||||
|
await portalLinksApi.create(payload);
|
||||||
|
toast.success("已添加");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadLinks();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("确定删除该快捷门户入口?")) return;
|
||||||
|
try {
|
||||||
|
await portalLinksApi.delete(id);
|
||||||
|
toast.success("已删除");
|
||||||
|
await loadLinks();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← 设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
快捷门户
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
添加税务、公积金等门户链接,侧栏可快速打开。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="ml-2">添加入口</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : links.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
暂无快捷门户入口。添加后将显示在左侧边栏「快捷门户」区域。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>链接</TableHead>
|
||||||
|
<TableHead className="w-[120px]">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{links.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline truncate max-w-[280px] inline-block"
|
||||||
|
>
|
||||||
|
{item.url}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" asChild title="打开">
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="portal-name">显示名称</Label>
|
||||||
|
<Input
|
||||||
|
id="portal-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="如:电子税务局、公积金"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="portal-url">门户链接</Label>
|
||||||
|
<Input
|
||||||
|
id="portal-url"
|
||||||
|
type="url"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||||
|
{editingId ? "保存" : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
frontend/app/(main)/settings/templates/page.tsx
Normal file
175
frontend/app/(main)/settings/templates/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
|
||||||
|
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function SettingsTemplatesPage() {
|
||||||
|
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await templatesApi.list();
|
||||||
|
setTemplates(list);
|
||||||
|
} catch {
|
||||||
|
toast.error("加载模板列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
|
||||||
|
if (![".xlsx", ".xltx", ".docx", ".dotx"].includes(ext)) {
|
||||||
|
toast.error("仅支持 .xlsx、.xltx、.docx、.dotx 文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await templatesApi.upload(file);
|
||||||
|
toast.success(`已上传:${file.name}`);
|
||||||
|
await loadTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = () => setDragOver(false);
|
||||||
|
|
||||||
|
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (ts: number) =>
|
||||||
|
new Date(ts * 1000).toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← 设置
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>模板库</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
上传报价单 Excel(.xlsx / .xltx)或合同 Word(.docx / .dotx),生成报价/合同时可选择使用。
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||||
|
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xltx,.docx,.dotx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.spreadsheetml.template,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.wordprocessingml.template"
|
||||||
|
onChange={onSelectFile}
|
||||||
|
className="hidden"
|
||||||
|
id="template-upload"
|
||||||
|
/>
|
||||||
|
<label htmlFor="template-upload" className="cursor-pointer block">
|
||||||
|
<Upload className="h-10 w-10 mx-auto text-muted-foreground" />
|
||||||
|
<p className="mt-2 text-sm font-medium">拖拽文件到此处,或点击选择</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">仅支持 .xlsx、.xltx、.docx、.dotx</p>
|
||||||
|
</label>
|
||||||
|
{uploading && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground flex items-center justify-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
上传中…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">已上传模板</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无模板</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>文件名</TableHead>
|
||||||
|
<TableHead>大小</TableHead>
|
||||||
|
<TableHead>上传时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<TableRow key={t.name}>
|
||||||
|
<TableCell>
|
||||||
|
{t.type === "excel" ? (
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{t.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{(t.size / 1024).toFixed(1)} KB
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatDate(t.uploaded_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,16 +13,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2 } from "lucide-react";
|
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2, Plus, Search, CloudUpload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
customersApi,
|
customersApi,
|
||||||
projectsApi,
|
projectsApi,
|
||||||
|
templatesApi,
|
||||||
|
pushProjectToCloud,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadFileAsBlob,
|
downloadFileAsBlob,
|
||||||
type CustomerRead,
|
type CustomerRead,
|
||||||
type QuoteGenerateResponse,
|
type QuoteGenerateResponse,
|
||||||
|
type TemplateInfo,
|
||||||
} from "@/lib/api/client";
|
} from "@/lib/api/client";
|
||||||
|
|
||||||
export default function WorkspacePage() {
|
export default function WorkspacePage() {
|
||||||
@@ -34,10 +46,19 @@ export default function WorkspacePage() {
|
|||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [generatingQuote, setGeneratingQuote] = useState(false);
|
const [generatingQuote, setGeneratingQuote] = useState(false);
|
||||||
|
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
|
||||||
|
const [newCustomerName, setNewCustomerName] = useState("");
|
||||||
|
const [newCustomerContact, setNewCustomerContact] = useState("");
|
||||||
|
const [newCustomerTags, setNewCustomerTags] = useState("");
|
||||||
|
const [addingCustomer, setAddingCustomer] = useState(false);
|
||||||
|
const [quoteTemplates, setQuoteTemplates] = useState<TemplateInfo[]>([]);
|
||||||
|
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
|
||||||
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
|
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
|
||||||
|
|
||||||
const loadCustomers = useCallback(async () => {
|
const loadCustomers = useCallback(async (search?: string) => {
|
||||||
try {
|
try {
|
||||||
const list = await customersApi.list();
|
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
|
||||||
setCustomers(list);
|
setCustomers(list);
|
||||||
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -46,8 +67,50 @@ export default function WorkspacePage() {
|
|||||||
}, [customerId]);
|
}, [customerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCustomers();
|
loadCustomers(customerSearch);
|
||||||
}, [loadCustomers]);
|
}, [loadCustomers, customerSearch]);
|
||||||
|
|
||||||
|
const loadQuoteTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await templatesApi.list();
|
||||||
|
setQuoteTemplates(list.filter((t) => t.type === "excel"));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuoteTemplates();
|
||||||
|
}, [loadQuoteTemplates]);
|
||||||
|
|
||||||
|
const handleAddCustomer = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newCustomerName.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast.error("请填写客户名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddingCustomer(true);
|
||||||
|
try {
|
||||||
|
const created = await customersApi.create({
|
||||||
|
name,
|
||||||
|
contact_info: newCustomerContact.trim() || null,
|
||||||
|
tags: newCustomerTags.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success("客户已添加");
|
||||||
|
setAddCustomerOpen(false);
|
||||||
|
setNewCustomerName("");
|
||||||
|
setNewCustomerContact("");
|
||||||
|
setNewCustomerTags("");
|
||||||
|
await loadCustomers(customerSearch);
|
||||||
|
setCustomerId(String(created.id));
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "添加失败";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setAddingCustomer(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
if (!customerId || !rawText.trim()) {
|
if (!customerId || !rawText.trim()) {
|
||||||
@@ -72,6 +135,34 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePushToCloud = async (platform: "feishu" | "yuque" | "tencent") => {
|
||||||
|
if (projectId == null) return;
|
||||||
|
const md = solutionMd?.trim() || "";
|
||||||
|
if (!md) {
|
||||||
|
toast.error("请先在编辑器中填写方案内容后再推送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPushToCloudLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await pushProjectToCloud(projectId, {
|
||||||
|
platform,
|
||||||
|
body_md: md,
|
||||||
|
});
|
||||||
|
toast.success("已推送到云文档", {
|
||||||
|
action: res.url
|
||||||
|
? {
|
||||||
|
label: "打开链接",
|
||||||
|
onClick: () => window.open(res.url, "_blank"),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "推送失败");
|
||||||
|
} finally {
|
||||||
|
setPushToCloudLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveToArchive = async () => {
|
const handleSaveToArchive = async () => {
|
||||||
if (projectId == null) {
|
if (projectId == null) {
|
||||||
toast.error("请先进行 AI 解析");
|
toast.error("请先进行 AI 解析");
|
||||||
@@ -96,7 +187,10 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
setGeneratingQuote(true);
|
setGeneratingQuote(true);
|
||||||
try {
|
try {
|
||||||
const res = await projectsApi.generateQuote(projectId);
|
const res = await projectsApi.generateQuote(
|
||||||
|
projectId,
|
||||||
|
selectedQuoteTemplate || undefined
|
||||||
|
);
|
||||||
setLastQuote(res);
|
setLastQuote(res);
|
||||||
toast.success("报价单已生成");
|
toast.success("报价单已生成");
|
||||||
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
||||||
@@ -120,7 +214,10 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
setGeneratingQuote(true);
|
setGeneratingQuote(true);
|
||||||
try {
|
try {
|
||||||
const res = await projectsApi.generateQuote(projectId);
|
const res = await projectsApi.generateQuote(
|
||||||
|
projectId,
|
||||||
|
selectedQuoteTemplate || undefined
|
||||||
|
);
|
||||||
setLastQuote(res);
|
setLastQuote(res);
|
||||||
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
||||||
toast.success("PDF 已下载");
|
toast.success("PDF 已下载");
|
||||||
@@ -158,18 +255,97 @@ export default function WorkspacePage() {
|
|||||||
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
|
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>客户</Label>
|
<Label>客户</Label>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索客户名称/联系方式"
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||||
|
className="pl-8 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
<Select value={customerId} onValueChange={setCustomerId}>
|
<Select value={customerId} onValueChange={setCustomerId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="flex-1">
|
||||||
<SelectValue placeholder="选择客户" />
|
<SelectValue placeholder="选择客户" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{customers.map((c) => (
|
{customers.map((c) => (
|
||||||
<SelectItem key={c.id} value={String(c.id)}>
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
{c.name}
|
{c.name}
|
||||||
|
{c.tags && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
({c.tags})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Dialog open={addCustomerOpen} onOpenChange={setAddCustomerOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" size="icon" variant="outline" title="新建客户">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建客户</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleAddCustomer}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customer-name">客户名称</Label>
|
||||||
|
<Input
|
||||||
|
id="customer-name"
|
||||||
|
value={newCustomerName}
|
||||||
|
onChange={(e) => setNewCustomerName(e.target.value)}
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customer-contact">联系方式</Label>
|
||||||
|
<Input
|
||||||
|
id="customer-contact"
|
||||||
|
value={newCustomerContact}
|
||||||
|
onChange={(e) => setNewCustomerContact(e.target.value)}
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customer-tags">标签(逗号分隔,用于收纳筛选)</Label>
|
||||||
|
<Input
|
||||||
|
id="customer-tags"
|
||||||
|
value={newCustomerTags}
|
||||||
|
onChange={(e) => setNewCustomerTags(e.target.value)}
|
||||||
|
placeholder="如:重点客户, 已签约"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAddCustomerOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={addingCustomer}>
|
||||||
|
{addingCustomer ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"添加"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<Label>微信/客户需求文本</Label>
|
<Label>微信/客户需求文本</Label>
|
||||||
@@ -220,7 +396,26 @@ export default function WorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Bar */}
|
{/* Floating Action Bar */}
|
||||||
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)]">
|
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)] flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">报价模板</span>
|
||||||
|
<Select
|
||||||
|
value={selectedQuoteTemplate || "__latest__"}
|
||||||
|
onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] h-8">
|
||||||
|
<SelectValue placeholder="使用最新上传" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__latest__">使用最新上传</SelectItem>
|
||||||
|
{quoteTemplates.map((t) => (
|
||||||
|
<SelectItem key={t.name} value={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -260,6 +455,30 @@ export default function WorkspacePage() {
|
|||||||
)}
|
)}
|
||||||
<span className="ml-1.5">导出 PDF</span>
|
<span className="ml-1.5">导出 PDF</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Select
|
||||||
|
value="__none__"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === "feishu" || v === "yuque" || v === "tencent") handlePushToCloud(v);
|
||||||
|
}}
|
||||||
|
disabled={pushToCloudLoading || projectId == null}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-8">
|
||||||
|
{pushToCloudLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CloudUpload className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
<SelectValue placeholder="推送到云文档" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
推送到云文档
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="feishu">飞书文档</SelectItem>
|
||||||
|
<SelectItem value="yuque">语雀</SelectItem>
|
||||||
|
<SelectItem value="tencent">腾讯文档</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
{projectId != null && (
|
{projectId != null && (
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
当前项目 #{projectId}
|
当前项目 #{projectId}
|
||||||
|
|||||||
@@ -2,27 +2,63 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
Settings,
|
Settings,
|
||||||
Building2,
|
|
||||||
Globe,
|
Globe,
|
||||||
PiggyBank,
|
FileStack,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HistoricalReferences } from "@/components/historical-references";
|
import { HistoricalReferences } from "@/components/historical-references";
|
||||||
|
import { cloudDocsApi, portalLinksApi, type CloudDocLinkRead, type PortalLinkRead } from "@/lib/api/client";
|
||||||
const QUICK_LINKS = [
|
|
||||||
{ label: "国家税务总局门户", href: "https://www.chinatax.gov.cn", icon: Building2 },
|
|
||||||
{ label: "电子税务局", href: "https://etax.chinatax.gov.cn", icon: Globe },
|
|
||||||
{ label: "公积金管理中心", href: "https://www.12329.com.cn", icon: PiggyBank },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
|
||||||
|
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
|
||||||
|
|
||||||
|
const loadCloudDocs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await cloudDocsApi.list();
|
||||||
|
setCloudDocs(list);
|
||||||
|
} catch {
|
||||||
|
setCloudDocs([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCloudDocs();
|
||||||
|
}, [loadCloudDocs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCloudDocsChanged = () => loadCloudDocs();
|
||||||
|
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||||
|
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||||
|
}, [loadCloudDocs]);
|
||||||
|
|
||||||
|
const loadPortalLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await portalLinksApi.list();
|
||||||
|
setPortalLinks(list);
|
||||||
|
} catch {
|
||||||
|
setPortalLinks([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPortalLinks();
|
||||||
|
}, [loadPortalLinks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPortalLinksChanged = () => loadPortalLinks();
|
||||||
|
window.addEventListener("portal-links-changed", onPortalLinksChanged);
|
||||||
|
return () => window.removeEventListener("portal-links-changed", onPortalLinksChanged);
|
||||||
|
}, [loadPortalLinks]);
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/workspace", label: "需求与方案", icon: FileText },
|
{ href: "/workspace", label: "需求与方案", icon: FileText },
|
||||||
@@ -54,25 +90,70 @@ export function AppSidebar() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="p-2">
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||||
<p className="text-xs font-medium text-muted-foreground px-2 mb-2">
|
<div className="p-2 shrink-0">
|
||||||
快捷门户
|
<div className="flex items-center justify-between px-2 mb-2">
|
||||||
</p>
|
<p className="text-xs font-medium text-muted-foreground">云文档</p>
|
||||||
|
<Link
|
||||||
|
href="/settings/cloud-docs"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
title="管理云文档入口"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{QUICK_LINKS.map((link) => (
|
{cloudDocs.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground px-2">暂无入口,去设置添加</p>
|
||||||
|
) : (
|
||||||
|
cloudDocs.map((doc) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={doc.id}
|
||||||
href={link.href}
|
href={doc.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<link.icon className="h-4 w-4 shrink-0" />
|
<FileStack className="h-4 w-4 shrink-0" />
|
||||||
{link.label}
|
<span className="truncate">{doc.name}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 shrink-0">
|
||||||
|
<div className="flex items-center justify-between px-2 mb-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">快捷门户</p>
|
||||||
|
<Link
|
||||||
|
href="/settings/portal-links"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
title="管理快捷门户"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{portalLinks.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground px-2">暂无入口,去设置添加</p>
|
||||||
|
) : (
|
||||||
|
portalLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{link.name}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pathname === "/workspace" && <HistoricalReferences />}
|
{pathname === "/workspace" && <HistoricalReferences />}
|
||||||
|
|||||||
@@ -1,22 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { listProjects, type ProjectRead } from "@/lib/api/client";
|
import {
|
||||||
import { Copy, Search, FileText } from "lucide-react";
|
listProjects,
|
||||||
|
projectsApi,
|
||||||
|
type ProjectRead,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import { Copy, Search, FileText, Eye, Pencil, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
function parseTags(tagsStr: string | null | undefined): string[] {
|
||||||
|
if (!tagsStr?.trim()) return [];
|
||||||
|
return tagsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoricalReferences() {
|
export function HistoricalReferences() {
|
||||||
const [projects, setProjects] = useState<ProjectRead[]>([]);
|
const [projects, setProjects] = useState<ProjectRead[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [previewProject, setPreviewProject] = useState<ProjectRead | null>(null);
|
||||||
|
const [editProject, setEditProject] = useState<ProjectRead | null>(null);
|
||||||
|
const [editRaw, setEditRaw] = useState("");
|
||||||
|
const [editMd, setEditMd] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listProjects();
|
const data = await listProjects(
|
||||||
|
selectedTag ? { customer_tag: selectedTag } : undefined
|
||||||
|
);
|
||||||
setProjects(data);
|
setProjects(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("加载历史项目失败");
|
toast.error("加载历史项目失败");
|
||||||
@@ -24,19 +61,32 @@ export function HistoricalReferences() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [selectedTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const filtered = search.trim()
|
const allTags = useMemo(() => {
|
||||||
? projects.filter(
|
const set = new Set<string>();
|
||||||
|
projects.forEach((p) =>
|
||||||
|
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
|
||||||
|
);
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = projects;
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.raw_requirement.toLowerCase().includes(search.toLowerCase()) ||
|
p.raw_requirement.toLowerCase().includes(q) ||
|
||||||
(p.ai_solution_md || "").toLowerCase().includes(search.toLowerCase())
|
(p.ai_solution_md || "").toLowerCase().includes(q)
|
||||||
)
|
);
|
||||||
: projects.slice(0, 10);
|
}
|
||||||
|
return list.slice(0, 20);
|
||||||
|
}, [projects, search]);
|
||||||
|
|
||||||
const copySnippet = (text: string, label: string) => {
|
const copySnippet = (text: string, label: string) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -44,13 +94,59 @@ export function HistoricalReferences() {
|
|||||||
toast.success(`已复制 ${label}`);
|
toast.success(`已复制 ${label}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPreview = (p: ProjectRead) => setPreviewProject(p);
|
||||||
|
const openEdit = (p: ProjectRead) => {
|
||||||
|
setEditProject(p);
|
||||||
|
setEditRaw(p.raw_requirement);
|
||||||
|
setEditMd(p.ai_solution_md || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editProject) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await projectsApi.update(editProject.id, {
|
||||||
|
raw_requirement: editRaw,
|
||||||
|
ai_solution_md: editMd || null,
|
||||||
|
});
|
||||||
|
toast.success("已保存");
|
||||||
|
setEditProject(null);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-2">
|
<div className="border-t p-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
|
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
历史参考
|
历史参考
|
||||||
</p>
|
</p>
|
||||||
<div className="relative mb-2">
|
<div className="space-y-2 mb-2">
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<Select
|
||||||
|
value={selectedTag || "__all__"}
|
||||||
|
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs flex-1">
|
||||||
|
<SelectValue placeholder="按标签收纳" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__" className="text-xs">
|
||||||
|
全部
|
||||||
|
</SelectItem>
|
||||||
|
{allTags.map((t) => (
|
||||||
|
<SelectItem key={t} value={t} className="text-xs">
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索项目..."
|
placeholder="搜索项目..."
|
||||||
@@ -59,6 +155,7 @@ export function HistoricalReferences() {
|
|||||||
className="h-8 pl-7 text-xs"
|
className="h-8 pl-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-xs text-muted-foreground px-2">加载中...</p>
|
<p className="text-xs text-muted-foreground px-2">加载中...</p>
|
||||||
@@ -72,20 +169,43 @@ export function HistoricalReferences() {
|
|||||||
"rounded border bg-background/50 p-2 text-xs space-y-1"
|
"rounded border bg-background/50 p-2 text-xs space-y-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="font-medium text-foreground line-clamp-1">
|
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
|
||||||
项目 #{p.id}
|
<span>项目 #{p.id}</span>
|
||||||
|
{p.customer?.tags && (
|
||||||
|
<span className="text-muted-foreground font-normal truncate max-w-[60%]">
|
||||||
|
{parseTags(p.customer.tags).slice(0, 2).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground line-clamp-2">
|
<p className="text-muted-foreground line-clamp-2">
|
||||||
{p.raw_requirement.slice(0, 80)}…
|
{p.raw_requirement.slice(0, 80)}…
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1 pt-1">
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-1.5 text-xs"
|
className="h-6 px-1.5 text-xs"
|
||||||
onClick={() =>
|
onClick={() => openPreview(p)}
|
||||||
copySnippet(p.raw_requirement, "原始需求")
|
title="预览"
|
||||||
}
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-0.5" />
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-xs"
|
||||||
|
onClick={() => openEdit(p)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3 mr-0.5" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-xs"
|
||||||
|
onClick={() => copySnippet(p.raw_requirement, "原始需求")}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3 mr-0.5" />
|
<Copy className="h-3 w-3 mr-0.5" />
|
||||||
需求
|
需求
|
||||||
@@ -94,9 +214,7 @@ export function HistoricalReferences() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-1.5 text-xs"
|
className="h-6 px-1.5 text-xs"
|
||||||
onClick={() =>
|
onClick={() => copySnippet(p.ai_solution_md || "", "方案")}
|
||||||
copySnippet(p.ai_solution_md || "", "方案")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3 mr-0.5" />
|
<Copy className="h-3 w-3 mr-0.5" />
|
||||||
方案
|
方案
|
||||||
@@ -106,6 +224,80 @@ export function HistoricalReferences() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 预览弹窗 */}
|
||||||
|
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
项目 #{previewProject?.id}
|
||||||
|
{previewProject?.customer?.name && (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||||
|
{previewProject.customer.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="requirement" className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="requirement">原始需求</TabsTrigger>
|
||||||
|
<TabsTrigger value="solution">方案</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="requirement" className="mt-2 overflow-auto flex-1 min-h-0">
|
||||||
|
<pre className="text-xs whitespace-pre-wrap bg-muted/50 p-3 rounded-md">
|
||||||
|
{previewProject?.raw_requirement ?? ""}
|
||||||
|
</pre>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="solution" className="mt-2 overflow-auto flex-1 min-h-0">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
||||||
|
{previewProject?.ai_solution_md ? (
|
||||||
|
<ReactMarkdown>{previewProject.ai_solution_md}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">暂无方案</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 二次编辑弹窗 */}
|
||||||
|
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑 项目 #{editProject?.id}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2 flex-1 min-h-0 overflow-auto">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>原始需求</Label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
|
||||||
|
value={editRaw}
|
||||||
|
onChange={(e) => setEditRaw(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>方案 (Markdown)</Label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
|
||||||
|
value={editMd}
|
||||||
|
onChange={(e) => setEditMd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditProject(null)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveEdit} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
frontend/components/ui/dialog.tsx
Normal file
119
frontend/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
80
frontend/components/ui/table.tsx
Normal file
80
frontend/components/ui/table.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
@@ -18,17 +18,20 @@ export interface CustomerRead {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
contact_info: string | null;
|
contact_info: string | null;
|
||||||
|
tags: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerCreate {
|
export interface CustomerCreate {
|
||||||
name: string;
|
name: string;
|
||||||
contact_info?: string | null;
|
contact_info?: string | null;
|
||||||
|
tags?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerUpdate {
|
export interface CustomerUpdate {
|
||||||
name?: string;
|
name?: string;
|
||||||
contact_info?: string | null;
|
contact_info?: string | null;
|
||||||
|
tags?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequirementAnalyzeRequest {
|
export interface RequirementAnalyzeRequest {
|
||||||
@@ -69,7 +72,68 @@ export interface FinanceSyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceSyncResponse {
|
export interface FinanceSyncResponse {
|
||||||
items: FinanceSyncResult[];
|
status: string;
|
||||||
|
new_files: number;
|
||||||
|
details: FinanceSyncResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceRecordRead {
|
||||||
|
id: number;
|
||||||
|
month: string;
|
||||||
|
type: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
amount: number | null;
|
||||||
|
billing_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateInfo {
|
||||||
|
name: string;
|
||||||
|
type: "excel" | "word";
|
||||||
|
size: number;
|
||||||
|
uploaded_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfig {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
provider: string;
|
||||||
|
api_key: string;
|
||||||
|
base_url: string;
|
||||||
|
model_name: string;
|
||||||
|
temperature: number;
|
||||||
|
system_prompt_override: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfigListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
model_name: string;
|
||||||
|
base_url: string;
|
||||||
|
api_key_configured: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfigCreate {
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
api_key?: string;
|
||||||
|
base_url?: string;
|
||||||
|
model_name?: string;
|
||||||
|
temperature?: number;
|
||||||
|
system_prompt_override?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIConfigUpdate {
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
api_key?: string;
|
||||||
|
base_url?: string;
|
||||||
|
model_name?: string;
|
||||||
|
temperature?: number;
|
||||||
|
system_prompt_override?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectRead {
|
export interface ProjectRead {
|
||||||
@@ -84,6 +148,7 @@ export interface ProjectRead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectUpdate {
|
export interface ProjectUpdate {
|
||||||
|
raw_requirement?: string | null;
|
||||||
ai_solution_md?: string | null;
|
ai_solution_md?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
@@ -112,10 +177,15 @@ async function request<T>(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let detail = text;
|
let detail: string = text;
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(text);
|
const j = JSON.parse(text);
|
||||||
detail = j.detail ?? text;
|
const raw = j.detail ?? text;
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
detail = raw.map((x: { msg?: string }) => x.msg ?? JSON.stringify(x)).join("; ");
|
||||||
|
} else {
|
||||||
|
detail = typeof raw === "string" ? raw : JSON.stringify(raw);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep text
|
// keep text
|
||||||
}
|
}
|
||||||
@@ -176,7 +246,10 @@ export async function downloadFileAsBlob(
|
|||||||
// --------------- Customers ---------------
|
// --------------- Customers ---------------
|
||||||
|
|
||||||
export const customersApi = {
|
export const customersApi = {
|
||||||
list: () => request<CustomerRead[]>("/customers/"),
|
list: (params?: { q?: string }) =>
|
||||||
|
request<CustomerRead[]>(
|
||||||
|
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/"
|
||||||
|
),
|
||||||
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
||||||
create: (body: CustomerCreate) =>
|
create: (body: CustomerCreate) =>
|
||||||
request<CustomerRead>("/customers/", {
|
request<CustomerRead>("/customers/", {
|
||||||
@@ -207,10 +280,11 @@ export const projectsApi = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
generateQuote: (projectId: number) =>
|
generateQuote: (projectId: number, template?: string | null) =>
|
||||||
request<QuoteGenerateResponse>(`/projects/${projectId}/generate_quote`, {
|
request<QuoteGenerateResponse>(
|
||||||
method: "POST",
|
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
|
||||||
}),
|
{ method: "POST" }
|
||||||
|
),
|
||||||
|
|
||||||
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
||||||
request<ContractGenerateResponse>(
|
request<ContractGenerateResponse>(
|
||||||
@@ -222,20 +296,285 @@ export const projectsApi = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listProjects(): Promise<ProjectRead[]> {
|
export async function listProjects(params?: {
|
||||||
return request<ProjectRead[]>("/projects/");
|
customer_tag?: string;
|
||||||
|
}): Promise<ProjectRead[]> {
|
||||||
|
const searchParams: Record<string, string> = {};
|
||||||
|
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
|
||||||
|
return request<ProjectRead[]>("/projects/", {
|
||||||
|
params: searchParams,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProject(projectId: number): Promise<ProjectRead> {
|
export async function getProject(projectId: number): Promise<ProjectRead> {
|
||||||
return request<ProjectRead>(`/projects/${projectId}`);
|
return request<ProjectRead>(`/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------- Settings / Templates ---------------
|
||||||
|
|
||||||
|
export const templatesApi = {
|
||||||
|
list: () => request<TemplateInfo[]>("/settings/templates"),
|
||||||
|
|
||||||
|
upload: async (file: File): Promise<{ name: string; path: string }> => {
|
||||||
|
const base = apiBase();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await fetch(`${base}/settings/templates/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
detail = j.detail ?? text;
|
||||||
|
} catch {
|
||||||
|
// keep text
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- AI Settings ---------------
|
||||||
|
|
||||||
|
export const aiSettingsApi = {
|
||||||
|
get: () => request<AIConfig>("/settings/ai"),
|
||||||
|
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
|
||||||
|
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
|
||||||
|
create: (body: AIConfigCreate) =>
|
||||||
|
request<AIConfig>("/settings/ai", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: AIConfigUpdate) =>
|
||||||
|
request<AIConfig>(`/settings/ai/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
|
||||||
|
activate: (id: string) =>
|
||||||
|
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
|
||||||
|
test: (configId?: string) =>
|
||||||
|
request<{ status: string; message: string }>(
|
||||||
|
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
|
||||||
|
{ method: "POST" }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Email Configs (multi-account sync) ---------------
|
||||||
|
|
||||||
|
export interface EmailConfigRead {
|
||||||
|
id: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
mailbox: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailConfigCreate {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
mailbox?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailConfigUpdate {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
mailbox?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailFolder {
|
||||||
|
raw: string;
|
||||||
|
decoded: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailConfigsApi = {
|
||||||
|
list: () => request<EmailConfigRead[]>("/settings/email"),
|
||||||
|
create: (body: EmailConfigCreate) =>
|
||||||
|
request<EmailConfigRead>("/settings/email", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: EmailConfigUpdate) =>
|
||||||
|
request<EmailConfigRead>(`/settings/email/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<void>(`/settings/email/${id}`, { method: "DELETE" }),
|
||||||
|
/** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */
|
||||||
|
listFolders: (configId: string) =>
|
||||||
|
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Cloud Docs (快捷入口) ---------------
|
||||||
|
|
||||||
|
export interface CloudDocLinkRead {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDocLinkCreate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDocLinkUpdate {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudDocsApi = {
|
||||||
|
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
|
||||||
|
create: (body: CloudDocLinkCreate) =>
|
||||||
|
request<CloudDocLinkRead>("/settings/cloud-docs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: CloudDocLinkUpdate) =>
|
||||||
|
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Cloud Doc Config (API 凭证) ---------------
|
||||||
|
|
||||||
|
export interface CloudDocConfigRead {
|
||||||
|
feishu: { app_id: string; app_secret_configured: boolean };
|
||||||
|
yuque: { token_configured: boolean; default_repo: string };
|
||||||
|
tencent: { client_id: string; client_secret_configured: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDocConfigUpdate {
|
||||||
|
feishu?: { app_id?: string; app_secret?: string };
|
||||||
|
yuque?: { token?: string; default_repo?: string };
|
||||||
|
tencent?: { client_id?: string; client_secret?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudDocConfigApi = {
|
||||||
|
get: () => request<CloudDocConfigRead>("/settings/cloud-doc-config"),
|
||||||
|
update: (body: CloudDocConfigUpdate) =>
|
||||||
|
request<CloudDocConfigRead>("/settings/cloud-doc-config", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------- Push to Cloud ---------------
|
||||||
|
|
||||||
|
export interface PushToCloudRequest {
|
||||||
|
platform: "feishu" | "yuque" | "tencent";
|
||||||
|
title?: string;
|
||||||
|
body_md?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushToCloudResponse {
|
||||||
|
url: string;
|
||||||
|
cloud_doc_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushProjectToCloud(
|
||||||
|
projectId: number,
|
||||||
|
body: PushToCloudRequest
|
||||||
|
): Promise<PushToCloudResponse> {
|
||||||
|
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- Portal Links (快捷门户) ---------------
|
||||||
|
|
||||||
|
export interface PortalLinkRead {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalLinkCreate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalLinkUpdate {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portalLinksApi = {
|
||||||
|
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
|
||||||
|
create: (body: PortalLinkCreate) =>
|
||||||
|
request<PortalLinkRead>("/settings/portal-links", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: PortalLinkUpdate) =>
|
||||||
|
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
|
||||||
|
};
|
||||||
|
|
||||||
// --------------- Finance ---------------
|
// --------------- Finance ---------------
|
||||||
|
|
||||||
export const financeApi = {
|
export const financeApi = {
|
||||||
sync: () =>
|
sync: () =>
|
||||||
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
|
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
|
||||||
|
|
||||||
|
/** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */
|
||||||
|
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
|
||||||
|
const base = apiBase();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await fetch(`${base}/finance/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
detail = j.detail ?? text;
|
||||||
|
} catch {
|
||||||
|
// keep text
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update amount/billing_date of a record (e.g. after manual review). */
|
||||||
|
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
|
||||||
|
request<FinanceRecordRead>(`/finance/records/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** List distinct months with records (YYYY-MM). */
|
||||||
|
listMonths: () => request<string[]>("/finance/months"),
|
||||||
|
|
||||||
|
/** List records for a month (YYYY-MM). */
|
||||||
|
listRecords: (month: string) =>
|
||||||
|
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
|
||||||
|
|
||||||
/** Returns the URL to download the zip (or use downloadFile with the path). */
|
/** Returns the URL to download the zip (or use downloadFile with the path). */
|
||||||
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
|
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
|
||||||
|
|
||||||
|
|||||||
22
frontend/lib/portal-config.ts
Normal file
22
frontend/lib/portal-config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 快捷门户地址配置
|
||||||
|
* 通过环境变量 NEXT_PUBLIC_* 覆盖,未设置时使用默认值
|
||||||
|
*/
|
||||||
|
const DEFAULTS = {
|
||||||
|
/** 国家税务总局门户 */
|
||||||
|
TAX_GATEWAY_URL: "https://www.chinatax.gov.cn",
|
||||||
|
/** 电子税务局(如上海电子税务局) */
|
||||||
|
TAX_PORTAL_URL: "https://etax.shanghai.chinatax.gov.cn:8443/",
|
||||||
|
/** 公积金管理中心 */
|
||||||
|
HOUSING_FUND_PORTAL_URL: "https://www.shzfgjj.cn/static/unit/web/",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const portalConfig = {
|
||||||
|
taxGatewayUrl:
|
||||||
|
process.env.NEXT_PUBLIC_TAX_GATEWAY_URL ?? DEFAULTS.TAX_GATEWAY_URL,
|
||||||
|
taxPortalUrl:
|
||||||
|
process.env.NEXT_PUBLIC_TAX_PORTAL_URL ?? DEFAULTS.TAX_PORTAL_URL,
|
||||||
|
housingFundPortalUrl:
|
||||||
|
process.env.NEXT_PUBLIC_HOUSING_FUND_PORTAL_URL ??
|
||||||
|
DEFAULTS.HOUSING_FUND_PORTAL_URL,
|
||||||
|
} as const;
|
||||||
7626
frontend/package-lock.json
generated
Normal file
7626
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,28 +9,29 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.2.18",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"react": "^18.3.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"react-dom": "^18.3.1",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"next": "14.2.18",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"react": "^18.3.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"react-dom": "^18.3.1",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
|
||||||
"@radix-ui/react-progress": "^1.1.0",
|
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"sonner": "^1.5.0"
|
"sonner": "^1.5.0",
|
||||||
|
"tailwind-merge": "^2.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
@@ -39,8 +40,7 @@
|
|||||||
"eslint-config-next": "14.2.18",
|
"eslint-config-next": "14.2.18",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"@tailwindcss/typography": "^0.5.15"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
231
local.py
Normal file
231
local.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
|
||||||
|
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
本地邮箱同步测试脚本(不依赖项目代码)
|
||||||
|
|
||||||
|
- 支持多个 IMAP 账户
|
||||||
|
- 每个账户单独测试登录、选择邮箱夹、列出未读邮件主题
|
||||||
|
- 结束时打印哪些账户成功、哪些失败(以及失败原因)
|
||||||
|
|
||||||
|
用来在对接「财务归档 → 同步」之前,本地先把邮箱配置调通。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
from email.header import decode_header
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 1. 在这里配置你的邮箱账户 =====
|
||||||
|
# 163 示例:IMAP 服务器 imap.163.com,端口 993,密码为「IMAP/SMTP 授权码」
|
||||||
|
ACCOUNTS = [
|
||||||
|
{
|
||||||
|
"name": "163 财务邮箱",
|
||||||
|
"host": "imap.163.com",
|
||||||
|
"port": 993,
|
||||||
|
"user": "danielghost@163.com",
|
||||||
|
"password": "TZjkMANWyYEDXui7",
|
||||||
|
"mailbox": "INBOX",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
# ===== 2. 工具函数 =====
|
||||||
|
|
||||||
|
def _decode_header_value(value: Optional[str]) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
parts = decode_header(value)
|
||||||
|
decoded = ""
|
||||||
|
for text, enc in parts:
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
decoded += text.decode(enc or "utf-8", errors="ignore")
|
||||||
|
else:
|
||||||
|
decoded += text
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def _list_mailboxes(imap: imaplib.IMAP4_SSL) -> List[Tuple[str, str]]:
|
||||||
|
"""列出所有邮箱夹,返回 [(raw_name, decoded_name)]"""
|
||||||
|
status, data = imap.list()
|
||||||
|
if status != "OK" or not data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: List[Tuple[str, str]] = []
|
||||||
|
for line in data:
|
||||||
|
if not isinstance(line, bytes):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
line_str = line.decode("ascii", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# 典型格式:b'(\\HasNoChildren) "/" "&UXZO1mWHTvZZOQ-"'
|
||||||
|
parts = line_str.split(" ")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
raw = parts[-1].strip('"')
|
||||||
|
# 简单处理 UTF-7,足够看中文「收件箱」
|
||||||
|
try:
|
||||||
|
decoded = raw.encode("latin1").decode("utf-7")
|
||||||
|
except Exception:
|
||||||
|
decoded = raw
|
||||||
|
result.append((raw, decoded))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
|
||||||
|
"""
|
||||||
|
尝试选中邮箱夹:
|
||||||
|
1. 直接 SELECT 配置的名字 / INBOX(读写、只读)
|
||||||
|
2. 尝试常见 UTF-7 编码收件箱(如 &XfJT0ZTx-)
|
||||||
|
3. 遍历 LIST,寻找带 \\Inbox 标记或名称包含 INBOX/收件箱 的文件夹,再 SELECT 实际名称
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
name = (mailbox or "INBOX").strip() or "INBOX"
|
||||||
|
|
||||||
|
# 1) 优先尝试配置名和标准 INBOX
|
||||||
|
primary_candidates = []
|
||||||
|
if name not in primary_candidates:
|
||||||
|
primary_candidates.append(name)
|
||||||
|
if "INBOX" not in primary_candidates:
|
||||||
|
primary_candidates.append("INBOX")
|
||||||
|
|
||||||
|
for candidate in primary_candidates:
|
||||||
|
for readonly in (False, True):
|
||||||
|
print(f" - 尝试 SELECT '{candidate}' (readonly={readonly}) ...")
|
||||||
|
try:
|
||||||
|
status, _ = imap.select(candidate, readonly=readonly)
|
||||||
|
if status == "OK":
|
||||||
|
print(" ✓ 直接 SELECT 成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠ 直接 SELECT 失败: {e}")
|
||||||
|
|
||||||
|
# 2) 尝试常见 UTF-7 编码收件箱
|
||||||
|
for candidate in ["&XfJT0ZTx-"]:
|
||||||
|
for readonly in (False, True):
|
||||||
|
print(f" - 尝试 UTF-7 收件箱 '{candidate}' (readonly={readonly}) ...")
|
||||||
|
try:
|
||||||
|
status, _ = imap.select(candidate, readonly=readonly)
|
||||||
|
if status == "OK":
|
||||||
|
print(" ✓ UTF-7 收件箱 SELECT 成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠ SELECT '{candidate}' 失败: {e}")
|
||||||
|
|
||||||
|
# 3) 通过 LIST 结果匹配带 \\Inbox 或名称包含 INBOX/收件箱 的文件夹
|
||||||
|
print(" - 尝试通过 LIST 匹配文件夹 ...")
|
||||||
|
try:
|
||||||
|
status, data = imap.list()
|
||||||
|
if status != "OK" or not data:
|
||||||
|
print(" ⚠ LIST 返回为空或非 OK")
|
||||||
|
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠ LIST 失败: {e}")
|
||||||
|
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for line in data:
|
||||||
|
if isinstance(line, bytes):
|
||||||
|
line_str = line.decode("utf-8", errors="ignore")
|
||||||
|
else:
|
||||||
|
line_str = line
|
||||||
|
|
||||||
|
if "\\Inbox" not in line_str and all(
|
||||||
|
kw not in line_str for kw in ['"INBOX"', '"Inbox"', '"收件箱"']
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = re.search(r'"([^"]+)"\s*$', line_str)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
actual_name = m.group(1)
|
||||||
|
print(f" 尝试 SELECT 列表中的 '{actual_name}' ...")
|
||||||
|
for readonly in (False, True):
|
||||||
|
try:
|
||||||
|
status2, _ = imap.select(actual_name, readonly=readonly)
|
||||||
|
if status2 == "OK":
|
||||||
|
print(" ✓ 通过 LIST 匹配成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠ SELECT '{actual_name}' (readonly={readonly}) 失败: {e}")
|
||||||
|
|
||||||
|
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
name: str
|
||||||
|
user: str
|
||||||
|
ok: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
unread_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 3. 主逻辑 =====
|
||||||
|
|
||||||
|
def sync_account(conf: dict) -> SyncResult:
|
||||||
|
name = conf.get("name") or conf.get("user") or "未命名账户"
|
||||||
|
host = conf["host"]
|
||||||
|
port = int(conf.get("port", 993))
|
||||||
|
user = conf["user"]
|
||||||
|
password = conf["password"]
|
||||||
|
mailbox = conf.get("mailbox", "INBOX")
|
||||||
|
|
||||||
|
print(f"\n=== 开始同步账户:{name} ({user}) ===")
|
||||||
|
try:
|
||||||
|
with imaplib.IMAP4_SSL(host, port) as imap:
|
||||||
|
print(f" - 连接 {host}:{port} ...")
|
||||||
|
imap.login(user, password)
|
||||||
|
print(" ✓ 登录成功")
|
||||||
|
|
||||||
|
if not _select_mailbox(imap, mailbox):
|
||||||
|
return SyncResult(name=name, user=user, ok=False, error=f"无法选择邮箱夹 {mailbox}")
|
||||||
|
|
||||||
|
status, data = imap.search(None, "UNSEEN")
|
||||||
|
if status != "OK":
|
||||||
|
return SyncResult(name=name, user=user, ok=False, error="SEARCH UNSEEN 失败")
|
||||||
|
|
||||||
|
ids = data[0].split()
|
||||||
|
print(f" ✓ 未读邮件数量:{len(ids)}")
|
||||||
|
for msg_id in ids[:10]: # 只看前 10 封,避免刷屏
|
||||||
|
status, msg_data = imap.fetch(msg_id, "(RFC822)")
|
||||||
|
if status != "OK" or not msg_data:
|
||||||
|
continue
|
||||||
|
raw_email = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
subject = _decode_header_value(msg.get("Subject"))
|
||||||
|
print(f" - 未读主题:{subject!r}")
|
||||||
|
return SyncResult(name=name, user=user, ok=True, unread_count=len(ids))
|
||||||
|
except Exception as e:
|
||||||
|
return SyncResult(name=name, user=user, ok=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
results: List[SyncResult] = []
|
||||||
|
for conf in ACCOUNTS:
|
||||||
|
results.append(sync_account(conf))
|
||||||
|
|
||||||
|
print("\n=== 汇总 ===")
|
||||||
|
for r in results:
|
||||||
|
if r.ok:
|
||||||
|
print(f"✓ {r.name} ({r.user}) 同步成功,未读 {r.unread_count} 封")
|
||||||
|
else:
|
||||||
|
print(f"✗ {r.name} ({r.user}) 同步失败:{r.error}")
|
||||||
|
|
||||||
|
failed = [r for r in results if not r.ok]
|
||||||
|
if failed:
|
||||||
|
print("\n以下账户未同步成功,请根据错误信息调整配置或在系统 UI 里重新选择邮箱夹:")
|
||||||
|
for r in failed:
|
||||||
|
print(f" - {r.name} ({r.user}):{r.error}")
|
||||||
|
else:
|
||||||
|
print("\n所有账户均同步成功。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -17,3 +17,4 @@ reportlab==4.2.5
|
|||||||
python-docx==1.1.2
|
python-docx==1.1.2
|
||||||
|
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
|
pymupdf==1.24.10
|
||||||
|
|||||||
Reference in New Issue
Block a user